From f9b63d95aa050578dafb8a2f188b90e1630a588b Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Fri, 6 Feb 2026 20:57:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 92 ++++++ src/components/calendar/Calendar.module.css | 188 ++++++++++++ src/components/calendar/Calendar.tsx | 121 ++++++++ src/components/calendar/index.ts | 2 + .../calendar/time/CalendarTime.module.css | 106 +++++++ src/components/calendar/time/CalendarTime.tsx | 270 ++++++++++++++++++ src/components/calendar/time/index.ts | 3 + 8 files changed, 783 insertions(+) create mode 100644 src/components/calendar/Calendar.module.css create mode 100644 src/components/calendar/Calendar.tsx create mode 100644 src/components/calendar/index.ts create mode 100644 src/components/calendar/time/CalendarTime.module.css create mode 100644 src/components/calendar/time/CalendarTime.tsx create mode 100644 src/components/calendar/time/index.ts diff --git a/package.json b/package.json index 268104e..aa4ec8f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "clsx": "^2.1.1", "next": "16.1.3", "react": "19.2.3", + "react-calendar": "^6.0.0", "react-dom": "19.2.3", "react-hook-form": "^7.71.1", "zod": "^4.3.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c87b7e8..81805e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: react: specifier: 19.2.3 version: 19.2.3 + react-calendar: + specifier: ^6.0.0 + version: 6.0.0(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) @@ -538,89 +541,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -720,24 +739,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.3': resolution: {integrity: sha512-UbFx69E2UP7MhzogJRMFvV9KdEn4sLGPicClwgqnLht2TEi204B71HuVfps3ymGAh0c44QRAF+ZmvZZhLLmhNg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.3': resolution: {integrity: sha512-SzGTfTjR5e9T+sZh5zXqG/oeRQufExxBF6MssXS7HPeZFE98JDhCRZXpSyCfWrWrYrzmnw/RVhlP2AxQm+wkRQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.3': resolution: {integrity: sha512-HlrDpj0v+JBIvQex1mXHq93Mht5qQmfyci+ZNwGClnAQldSfxI6h0Vupte1dSR4ueNv4q7qp5kTnmLOBIQnGow==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.3': resolution: {integrity: sha512-3gFCp83/LSduZMSIa+lBREP7+5e7FxpdBoc9QrCdmp+dapmTK9I+SLpY60Z39GDmTXSZA4huGg9WwmYbr6+WRw==} @@ -817,66 +840,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1185,41 +1221,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1302,6 +1346,9 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@wojtekmaj/date-utils@2.0.2': + resolution: {integrity: sha512-Do66mSlSNifFFuo3l9gNKfRMSFi26CRuQMsDJuuKO/ekrDWuTTtE4ZQxoFCUOG+NgxnpSeBq/k5TY8ZseEzLpA==} + JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -2028,6 +2075,9 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-user-locale@3.0.0: + resolution: {integrity: sha512-iJfHSmdYV39UUBw7Jq6GJzeJxUr4U+S03qdhVuDsR9gCEnfbqLy9gYDJFBJQL1riqolFUKQvx36mEkp2iGgJ3g==} + git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} @@ -2462,6 +2512,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + memoize@10.2.0: + resolution: {integrity: sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==} + engines: {node: '>=18'} + meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -2728,6 +2782,16 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-calendar@6.0.0: + resolution: {integrity: sha512-6wqaki3Us0DNDjZDr0DYIzhSFprNoy4FdPT9Pjy5aD2hJJVjtJwmdMT9VmrTUo949nlk35BOxehThxX62RkuRQ==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -3258,6 +3322,9 @@ packages: jsdom: optional: true + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -4528,6 +4595,8 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@wojtekmaj/date-utils@2.0.2': {} + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -5413,6 +5482,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-user-locale@3.0.0: + dependencies: + memoize: 10.2.0 + git-raw-commits@4.0.0: dependencies: dargs: 8.1.0 @@ -5822,6 +5895,10 @@ snapshots: math-intrinsics@1.1.0: {} + memoize@10.2.0: + dependencies: + mimic-function: 5.0.1 + meow@12.1.1: {} merge2@1.4.1: {} @@ -6067,6 +6144,17 @@ snapshots: queue-microtask@1.2.3: {} + react-calendar@6.0.0(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@wojtekmaj/date-utils': 2.0.2 + clsx: 2.1.1 + get-user-locale: 3.0.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + warning: 4.0.3 + optionalDependencies: + '@types/react': 19.2.8 + react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -6718,6 +6806,10 @@ snapshots: - tsx - yaml + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 + webpack-virtual-modules@0.6.2: {} which-boxed-primitive@1.1.1: diff --git a/src/components/calendar/Calendar.module.css b/src/components/calendar/Calendar.module.css new file mode 100644 index 0000000..fea8580 --- /dev/null +++ b/src/components/calendar/Calendar.module.css @@ -0,0 +1,188 @@ +/* 감싸는 박스 */ +.wrapper { + width: 336px; + padding: 16px 43px; /* ✅ 위아래 16 / 좌우 43 */ + background: #fff; + border: 1px solid #416ec8; + border-radius: 12px; + box-sizing: border-box; +} + +/* 달력 자체 border 제거 + 폰트 14 */ +:global(.react-calendar) { + width: 250px; /* ✅ 내부 달력 폭 250px 고정 -> 7등분하면 35.71px */ + border: none !important; + font-size: 14px; + font-family: inherit; +} + +/* ✅ 네비게이션: margin-bottom 제거 */ +:global(.react-calendar__navigation) { + display: flex; + height: 34px; + align-items: center; + justify-content: space-between; + margin-bottom: 0 !important; +} + +/* 연도 이동 버튼 제거 */ +:global(.react-calendar__navigation__prev2-button), +:global(.react-calendar__navigation__next2-button) { + display: none !important; +} + +/* ✅ 네비게이션 버튼 min-width:44px 제거 (강하게) */ +:global(.react-calendar__navigation button) { + min-width: 0 !important; + padding: 0 !important; + margin: 0 !important; + background: transparent; + border: none; + cursor: pointer; +} + +/* 월 이동 버튼(<, >) */ +:global(.react-calendar__navigation__prev-button), +:global(.react-calendar__navigation__next-button) { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +/* 가운데 타이틀 */ +:global(.react-calendar__navigation__label) { + font-size: 16px; + font-weight: 700; + background: transparent; + border: none; + cursor: default; + pointer-events: none; + margin: 0 !important; +} + +:global(.react-calendar__month-view__weekdays) { + color: #8b95a1; + margin-bottom: 0 !important; + + display: grid !important; + grid-template-columns: repeat(7, 1fr); +} + +:global(.react-calendar__month-view__weekdays__weekday) { + padding: 0 !important; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + margin: 0 !important; +} + +:global(.react-calendar__month-view__weekdays abbr) { + text-decoration: none; +} + +:global(.react-calendar__month-view__days) { + display: grid !important; + grid-template-columns: repeat(7, 1fr); + grid-auto-rows: 32px; +} + +:global(.react-calendar__tile) { + padding: 0 !important; + width: 100%; + max-width: none !important; + height: 32px; + font-size: 14px; + border-radius: 8px; + box-sizing: border-box; + + display: flex; + align-items: center; + justify-content: center; + margin: 0 !important; +} + +/* hover */ +:global(.react-calendar__tile:enabled:hover) { + background: #f2f4f6; + border-radius: 8px; +} + +/* 오늘 날짜 */ +:global(.react-calendar__tile--now) { + background: transparent !important; + color: #5189fa !important; +} + +/* 선택 날짜 */ +:global(.react-calendar__tile--active) { + background: #5189fa !important; + color: #fff !important; + border-radius: 8px !important; +} + +:global(.react-calendar__tile--active:enabled:hover), +:global(.react-calendar__tile--active:enabled:focus) { + background: #5189fa !important; + color: #fff !important; +} + +/* 오늘+선택 */ +:global(.react-calendar__tile--now.react-calendar__tile--active) { + background: #5189fa !important; + color: #fff !important; + border-radius: 8px !important; +} + +/* 주말 빨간색 제거 */ +:global(.react-calendar__month-view__days__day--weekend) { + color: inherit !important; +} + +/* 공휴일 스타일 제거 */ +:global(.react-calendar__tile--holiday) { + color: inherit !important; +} + +.arrowButton { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.arrowIcon { + width: 24px; + height: 24px; + filter: brightness(0) saturate(100%); +} + +.prev { + transform: rotate(90deg); +} + +.next { + transform: rotate(-90deg); +} + +/* 년/월 타이틀: 500 */ +:global(.react-calendar__navigation__label) { + font-weight: 500; + font-size: 14px; +} + +/* 요일(일 월 화 수 목 금 토): 500 */ +:global(.react-calendar__month-view__weekdays) { + font-weight: 500; + font-size: 14px; +} + +/* 날짜 숫자: 400 */ +:global(.react-calendar__tile) { + font-weight: 400; + font-size: 14px; +} diff --git a/src/components/calendar/Calendar.tsx b/src/components/calendar/Calendar.tsx new file mode 100644 index 0000000..de04e51 --- /dev/null +++ b/src/components/calendar/Calendar.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import Image from 'next/image'; +import ReactCalendar from 'react-calendar'; +import type { CalendarProps as ReactCalendarProps } from 'react-calendar'; +import 'react-calendar/dist/Calendar.css'; + +import ArrowIcon from '@/assets/icons/arrow/downArrowLarge.svg'; +import styles from './Calendar.module.css'; + +export type CalendarValue = Date | null; + +export type CalendarProps = { + value?: CalendarValue; + onChange?: (value: CalendarValue) => void; + + inputValue?: string; + inputYear?: number; + + onInputValueChange?: (next: string) => void; +}; + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +function formatYYYYMMDD(d: Date): string { + return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}`; +} + +function isValidDate(y: number, m: number, d: number): boolean { + if (m < 1 || m > 12) return false; + if (d < 1 || d > 31) return false; + const dt = new Date(y, m - 1, d); + return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d; +} + +function parseInputDate(inputValue?: string, inputYear?: number): Date | null { + const raw = (inputValue ?? '').trim(); + if (!raw) return null; + + const digits = raw.replace(/\D/g, ''); + + // YYYYMMDD + if (digits.length === 8) { + const y = Number(digits.slice(0, 4)); + const m = Number(digits.slice(4, 6)); + const d = Number(digits.slice(6, 8)); + if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null; + if (!isValidDate(y, m, d)) return null; + return new Date(y, m - 1, d); + } + + // MMDD + if (digits.length === 4) { + const y = inputYear ?? new Date().getFullYear(); + const m = Number(digits.slice(0, 2)); + const d = Number(digits.slice(2, 4)); + if (!Number.isFinite(m) || !Number.isFinite(d)) return null; + if (!isValidDate(y, m, d)) return null; + return new Date(y, m - 1, d); + } + + return null; +} + +export default function Calendar({ + value, + onChange, + inputValue, + inputYear, + onInputValueChange, +}: CalendarProps) { + const [internalValue, setInternalValue] = useState(() => { + const initial = parseInputDate(inputValue, inputYear); + return (value ?? initial ?? new Date()) as Date; + }); + + const inputDate = useMemo(() => parseInputDate(inputValue, inputYear), [inputValue, inputYear]); + + const currentValue = useMemo(() => { + if (typeof value !== 'undefined') return value; // Date 컨트롤드 우선 + if (inputDate) return inputDate; // inputValue 컨트롤드 + return internalValue; // 내부 상태 + }, [value, inputDate, internalValue]); + + const handleChange: NonNullable = (v) => { + const nextDate = Array.isArray(v) ? v[0] : v; + if (!nextDate) return; + + setInternalValue(nextDate); + onChange?.(nextDate); + + onInputValueChange?.(formatYYYYMMDD(nextDate)); + }; + + return ( +
+ String(date.getDate())} + prev2Label={null} + next2Label={null} + prevLabel={ + + prev + + } + nextLabel={ + + next + + } + /> +
+ ); +} diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts new file mode 100644 index 0000000..51ca2f0 --- /dev/null +++ b/src/components/calendar/index.ts @@ -0,0 +1,2 @@ +export { default } from './Calendar'; +export type { CalendarProps, CalendarValue } from './Calendar'; diff --git a/src/components/calendar/time/CalendarTime.module.css b/src/components/calendar/time/CalendarTime.module.css new file mode 100644 index 0000000..3d17146 --- /dev/null +++ b/src/components/calendar/time/CalendarTime.module.css @@ -0,0 +1,106 @@ +.container { + width: 288px; + height: 176px; + + display: flex; + gap: 12px; + + padding: 12px; + border-radius: 12px; + background: var(--color-background-inverse, #ffffff); + + border: 1px solid var(--color-interaction-hover, #416ec8); +} + +.periodCol { + width: 78px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.periodBtn { + width: 78px; + height: 40px; + + border-radius: 12px; + border: 1px solid #e2e8f0; + + background: var(--color-background-inverse, #ffffff); + color: var(--color-text-default, #64748b); + + font-size: 14px; + font-weight: 500; + line-height: 1; + + display: flex; + align-items: center; + justify-content: center; + + cursor: pointer; +} + +.periodBtnActive { + background: var(--color-brand-primary, #5189fa); + border-color: var(--color-brand-primary, #5189fa); + color: var(--color-background-inverse, #ffffff); +} + +.timeBox { + width: 172px; + height: 152px; + + border-radius: 12px; + border: 1px solid #e2e8f0; + background: var(--color-background-inverse, #ffffff); + + overflow: hidden; +} + +.timeList { + height: 100%; + overflow-y: auto; + + padding: 8px 8px 8px 16px; + + display: flex; + flex-direction: column; + gap: 0; +} + +.timeRow { + height: 34px; + display: flex; + align-items: center; +} + +.timeItem { + width: 100%; + height: 34px; + + border-radius: 8px; + display: flex; + align-items: center; + + font-size: 16px; + font-weight: 400; + line-height: 1; + + color: var(--color-text-default, #64748b); + cursor: pointer; +} +.timeItemActive { + background: var(--color-brand-primary, #5189fa); + color: #fff; +} + +.timeList::-webkit-scrollbar { + width: 3px; +} +.timeList::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.7); + border-radius: 999px; +} +.timeList::-webkit-scrollbar-track { + background: transparent; +} diff --git a/src/components/calendar/time/CalendarTime.tsx b/src/components/calendar/time/CalendarTime.tsx new file mode 100644 index 0000000..d6412cc --- /dev/null +++ b/src/components/calendar/time/CalendarTime.tsx @@ -0,0 +1,270 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import styles from './CalendarTime.module.css'; + +type Period = 'AM' | 'PM'; + +export type CalendarTimeProps = { + /** + * value 허용: + * - "H:MM" / "HH:MM" (예: "3:00", "13:30") + * - "HHMM" / "HMM" (예: "0300", "300", "1530") + * - "오전 3:00" / "오후 3:30" (공백 있어도/없어도) + */ + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; + + stepMinutes?: 5 | 10 | 15 | 30 | 60; + startHour?: number; + endHour?: number; + + defaultPeriod?: Period; + + /** ✅ onChange로 내보낼 포맷 (default: '24h') */ + outputFormat?: '24h' | 'ko'; + + ariaLabel?: string; + className?: string; +}; + +function clampHour(n: number): number { + return Math.max(0, Math.min(23, n)); +} + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +/** hour는 0 padding 안 함 (3:00), minute만 2자리 */ +function format24(h: number, m: number): string { + return `${h}:${pad2(m)}`; +} + +function to12hLabel(h24: number, m: number): string { + const h = h24 % 12 === 0 ? 12 : h24 % 12; + return `${h}:${pad2(m)}`; +} + +function formatKo(h24: number, m: number): string { + const isAM = h24 < 12; + const periodLabel = isAM ? '오전' : '오후'; + const h12 = h24 % 12 === 0 ? 12 : h24 % 12; + return `${periodLabel} ${h12}:${pad2(m)}`; +} + +function parseCoreTime(v: string): { h: number; m: number } | null { + const raw = v.trim(); + if (!raw) return null; + + const colon = /^(\d{1,2}):(\d{2})$/.exec(raw); + if (colon) { + const h = Number(colon[1]); + const m = Number(colon[2]); + if (Number.isNaN(h) || Number.isNaN(m)) return null; + if (h < 0 || h > 23 || m < 0 || m > 59) return null; + return { h, m }; + } + + const digits = /^(\d{3,4})$/.exec(raw); + if (digits) { + const s = digits[1]; + const hStr = s.length === 3 ? s.slice(0, 1) : s.slice(0, 2); + const mStr = s.slice(-2); + const h = Number(hStr); + const m = Number(mStr); + if (Number.isNaN(h) || Number.isNaN(m)) return null; + if (h < 0 || h > 23 || m < 0 || m > 59) return null; + return { h, m }; + } + + return null; +} + +function parseTime(v: string): { h: number; m: number } | null { + const raw = v.trim(); + if (!raw) return null; + + const periodMatch = /^(오전|오후|AM|PM)\s*/i.exec(raw); + const periodToken = periodMatch ? periodMatch[1] : null; + const rest = periodMatch ? raw.slice(periodMatch[0].length) : raw; + + const core = parseCoreTime(rest); + if (!core) return null; + + if (!periodToken) return core; + + const tokenUpper = periodToken.toUpperCase(); + const nextPeriod: Period = periodToken === '오전' || tokenUpper === 'AM' ? 'AM' : 'PM'; + + if (core.h >= 13) return core; + + const clockHour = core.h === 0 ? 12 : core.h; + if (clockHour < 1 || clockHour > 12) return null; + + const h24 = + nextPeriod === 'AM' + ? clockHour === 12 + ? 0 + : clockHour + : clockHour === 12 + ? 12 + : clockHour + 12; + + return { h: h24, m: core.m }; +} + +function convertKeepClockTime(h24: number, m: number, nextPeriod: Period): string { + const clockHour = h24 % 12 === 0 ? 12 : h24 % 12; + + const nextH24 = + nextPeriod === 'AM' + ? clockHour === 12 + ? 0 + : clockHour + : clockHour === 12 + ? 12 + : clockHour + 12; + + return format24(nextH24, m); +} + +export default function CalendarTime({ + value, + defaultValue, + onChange, + stepMinutes = 30, + startHour = 0, + endHour = 23, + defaultPeriod = 'PM', + outputFormat = '24h', + ariaLabel = 'time selector', + className, +}: CalendarTimeProps) { + const isControlled = typeof value === 'string'; + const [internalValue, setInternalValue] = useState(defaultValue ?? ''); + const [uiPeriod, setUiPeriod] = useState(defaultPeriod); + + const resolvedValue = (isControlled ? value : internalValue) ?? ''; + const parsed = resolvedValue ? parseTime(resolvedValue) : null; + + const derivedPeriod: Period = useMemo(() => { + if (parsed) return parsed.h < 12 ? 'AM' : 'PM'; + return uiPeriod; + }, [parsed, uiPeriod]); + + const hoursRange = useMemo(() => { + const s = clampHour(startHour); + const e = clampHour(endHour); + if (s <= e) return { s, e }; + return { s: 0, e: 23 }; + }, [startHour, endHour]); + + const normalizeOutput = useCallback( + (h: number, m: number) => (outputFormat === 'ko' ? formatKo(h, m) : format24(h, m)), + [outputFormat], + ); + + const commitValue = useCallback( + (nextRaw: string) => { + const p = parseTime(nextRaw); + const next = p ? normalizeOutput(p.h, p.m) : nextRaw; + + if (!isControlled) setInternalValue(next); + onChange?.(next); + }, + [isControlled, normalizeOutput, onChange], + ); + + const handleSetPeriod = useCallback( + (next: Period) => { + setUiPeriod(next); + + if (parsed) { + const nextValue24 = convertKeepClockTime(parsed.h, parsed.m, next); + commitValue(nextValue24); + } + }, + [commitValue, parsed], + ); + + const timeOptions = useMemo(() => { + const list: Array<{ value24: string; label: string }> = []; + + const periodStart = derivedPeriod === 'AM' ? 0 : 12; + const periodEnd = derivedPeriod === 'AM' ? 11 : 23; + + for (let h = periodStart; h <= periodEnd; h += 1) { + if (h < hoursRange.s || h > hoursRange.e) continue; + for (let m = 0; m < 60; m += stepMinutes) { + const value24 = format24(h, m); + list.push({ value24, label: to12hLabel(h, m) }); + } + } + + return list; + }, [derivedPeriod, hoursRange, stepMinutes]); + + const selected24 = parsed ? format24(parsed.h, parsed.m) : ''; + + const handlePick = useCallback( + (v24: string) => { + commitValue(v24); + + const p = parseTime(v24); + if (p) setUiPeriod(p.h < 12 ? 'AM' : 'PM'); + }, + [commitValue], + ); + + return ( +
+
+ + +
+ +
+
+ {timeOptions.map((t) => { + const active = t.value24 === selected24; + return ( +
+
handlePick(t.value24)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handlePick(t.value24); + } + }} + > + {t.label} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/calendar/time/index.ts b/src/components/calendar/time/index.ts new file mode 100644 index 0000000..fc89241 --- /dev/null +++ b/src/components/calendar/time/index.ts @@ -0,0 +1,3 @@ +// index.ts +export { default } from './CalendarTime'; +export type { CalendarTimeProps } from './CalendarTime';