diff --git a/package-lock.json b/package-lock.json index 2772cba14..269546567 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,14 @@ "name": "fe-weekly-mission", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^3.3.4", + "classnames": "^2.5.1", "next": "13.5.6", "react": "^18", "react-dom": "^18", - "styled-components": "^6.1.8" + "react-hook-form": "^7.51.4", + "styled-components": "^6.1.8", + "yup": "^1.4.0" }, "devDependencies": { "@types/node": "^20", @@ -22,6 +26,7 @@ "husky": "^9.0.11", "lint-staged": "^15.2.2", "prettier": "3.2.5", + "sass": "^1.77.1", "typescript": "^5" } }, @@ -120,6 +125,14 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.4.tgz", + "integrity": "sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -584,6 +597,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "devOptional": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -780,6 +806,18 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "devOptional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -794,7 +832,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, + "devOptional": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -879,6 +917,47 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "devOptional": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "devOptional": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/cli-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", @@ -1740,7 +1819,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, + "devOptional": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -1799,6 +1878,20 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2126,6 +2219,12 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", + "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==", + "devOptional": true + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -2222,6 +2321,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "devOptional": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", @@ -2281,7 +2392,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -2329,7 +2440,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -2362,7 +2473,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.12.0" } @@ -2979,6 +3090,15 @@ } } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "devOptional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -3258,7 +3378,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=8.6" }, @@ -3345,6 +3465,11 @@ "react-is": "^16.13.1" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3397,12 +3522,39 @@ "react": "^18.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.51.4", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz", + "integrity": "sha512-V14i8SEkh+V1gs6YtD0hdHYnoL4tp/HX/A45wWQN15CYr9bFRmmRdYStSO5L65lCCZRF+kYiSKhm9alqbcdiVA==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "devOptional": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -3613,6 +3765,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sass": { + "version": "1.77.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.1.tgz", + "integrity": "sha512-OMEyfirt9XEfyvocduUIOlUSkWOXS/LAt6oblR/ISXCTukyavjex+zQNm51pPCOiFKY1QpWvEH1EeCkgyV3I6w==", + "devOptional": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -4037,11 +4206,16 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "dependencies": { "is-number": "^7.0.0" }, @@ -4049,6 +4223,11 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/ts-api-utils": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", @@ -4401,6 +4580,28 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", + "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 4d82aaa17..ac3fbabc8 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,14 @@ "*.{js,jsx,ts,tsx}": "eslint" }, "dependencies": { + "@hookform/resolvers": "^3.3.4", + "classnames": "^2.5.1", "next": "13.5.6", "react": "^18", "react-dom": "^18", - "styled-components": "^6.1.8" + "react-hook-form": "^7.51.4", + "styled-components": "^6.1.8", + "yup": "^1.4.0" }, "devDependencies": { "@types/node": "^20", @@ -28,6 +32,7 @@ "husky": "^9.0.11", "lint-staged": "^15.2.2", "prettier": "3.2.5", + "sass": "^1.77.1", "typescript": "^5" } } diff --git a/pages/index.page.tsx b/pages/index.page.tsx index e18faddd0..f29f80d18 100644 --- a/pages/index.page.tsx +++ b/pages/index.page.tsx @@ -1,9 +1,11 @@ import { ReactElement } from 'react'; import Link from 'next/link'; import styles from '@/styles/pages/Home.module.css'; -import { Signup } from '@/src/components/Signup'; import { Button } from '@/src/components/Button'; import { MainLayout } from '@/src/components/Layout'; +import Form from '@/src/features/form-page/ui/Form'; +import SearchForm from '@/src/features/form-page/ui/SearchForm'; +import HookForm from '@/src/features/form-page/ui/HookForm'; export default function Home() { return ( @@ -21,7 +23,20 @@ export default function Home() { - +
+
+

일반 폼

+
+
+
+

검색 폼

+ +
+
+

리액트 훅 폼

+ +
+
); } diff --git a/public/images/eye-close.svg b/public/images/eye-close.svg new file mode 100644 index 000000000..11dc624c4 --- /dev/null +++ b/public/images/eye-close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/eye-open.svg b/public/images/eye-open.svg new file mode 100644 index 000000000..c5844bfff --- /dev/null +++ b/public/images/eye-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/heart.svg b/public/images/heart.svg new file mode 100644 index 000000000..3b6a801de --- /dev/null +++ b/public/images/heart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/menu.svg b/public/images/menu.svg new file mode 100644 index 000000000..b1e3b735c --- /dev/null +++ b/public/images/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/search.svg b/public/images/search.svg new file mode 100644 index 000000000..16888b880 --- /dev/null +++ b/public/images/search.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/star.svg b/public/images/star.svg new file mode 100644 index 000000000..08403be3a --- /dev/null +++ b/public/images/star.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/features/form-page/ui/Form.module.scss b/src/features/form-page/ui/Form.module.scss new file mode 100644 index 000000000..8ae40cc18 --- /dev/null +++ b/src/features/form-page/ui/Form.module.scss @@ -0,0 +1,16 @@ +@layer overrides { + .form { + display: flex; + flex-direction: column; + gap: 1.25rem; + width: 18.75rem; + } + .label { + color: #75a47f; + } + + .inputField { + font-size: 1.125rem; + font-weight: bold; + } +} diff --git a/src/features/form-page/ui/Form.tsx b/src/features/form-page/ui/Form.tsx new file mode 100644 index 000000000..43aa12f41 --- /dev/null +++ b/src/features/form-page/ui/Form.tsx @@ -0,0 +1,154 @@ +import classNames from 'classnames/bind'; +import * as Yup from 'yup'; + +import styles from './Form.module.scss'; +import { ChangeEvent, FormEvent, useState } from 'react'; +import Input from '@/src/shared/@common/ui/input'; + +const cx = classNames.bind(styles); + +const formSchema = Yup.object({ + email: Yup.string() + .trim() + .email('이메일 형식으로 입력해 주세요.') + .required('이메일을 입력해 주세요.'), + password: Yup.string() + .trim() + .min(8, '8자 이상 입력해 주세요.') + .required('비밀번호를 입력해 주세요.'), + passwordConfirm: Yup.string() + .trim() + .oneOf([Yup.ref('password')], '비밀번호가 일치하지 않습니다.') + .required('비밀번호를 입력해 주세요.'), +}); + +interface FormData { + [key: string]: { + value: string; + isError: boolean; + message: string; + }; +} + +const initialFormData: FormData = { + email: { + value: '', + isError: false, + message: '', + }, + password: { + value: '', + isError: false, + message: '', + }, + passwordConfirm: { + value: '', + isError: false, + message: '', + }, +}; + +export default function Form() { + const [formData, setFormData] = useState(initialFormData); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + const data = { + email: formData.email.value, + password: formData.password.value, + passwordConfirm: formData.passwordConfirm.value, + }; + + for (const key in formData) { + try { + await formSchema.validateAt(key, data); + + setFormData((prev) => ({ + ...prev, + [key]: { ...prev[key], isError: false, message: '' }, + })); + } catch (error) { + if (error instanceof Yup.ValidationError) { + setFormData((prev) => ({ + ...prev, + [key]: { ...prev[key], isError: true, message: error.message }, + })); + return; + } + } + } + + alert('제출 완료'); + }; + + const handleInputChange = (e: ChangeEvent) => { + const { name, value } = e.target; + + setFormData((prev) => ({ ...prev, [name]: { ...prev[name], value } })); + }; + + return ( + + + + 이메일 + + + + + {formData.email.message && ( + {formData.email.message} + )} + + + + 비밀번호 + + + + + {formData.password.message && ( + {formData.password.message} + )} + + + + 비밀번호 확인 + + + + + {formData.passwordConfirm.message && ( + {formData.passwordConfirm.message} + )} + + + + ); +} diff --git a/src/features/form-page/ui/HookForm.tsx b/src/features/form-page/ui/HookForm.tsx new file mode 100644 index 000000000..3802d244f --- /dev/null +++ b/src/features/form-page/ui/HookForm.tsx @@ -0,0 +1,114 @@ +import { useForm } from 'react-hook-form'; +import classNames from 'classnames/bind'; +import * as Yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; + +import styles from './Form.module.scss'; +import Input from '@/src/shared/@common/ui/input'; + +const cx = classNames.bind(styles); + +type FormValues = { + email: string; + password: string; + passwordConfirm: string; +}; + +const formSchema = Yup.object({ + email: Yup.string() + .trim() + .email('이메일 형식으로 입력해 주세요.') + .required('이메일을 입력해 주세요.'), + password: Yup.string() + .trim() + .min(8, '8자 이상 입력해 주세요.') + .required('비밀번호를 입력해 주세요.'), + passwordConfirm: Yup.string() + .trim() + .oneOf([Yup.ref('password')], '비밀번호가 일치하지 않습니다.') + .required('비밀번호를 입력해 주세요.'), +}); + +export default function HookForm() { + const { + register, + formState: { errors, isValid, isSubmitting }, + handleSubmit, + setError, + reset, + } = useForm({ + resolver: yupResolver(formSchema), + mode: 'all', + defaultValues: { + email: '', + password: '', + }, + }); + + const handleValidSubmit = (data: FormValues) => { + alert('제출 완료'); + }; + + return ( +
+ + + 이메일 + + + + + {errors.email?.message && ( + {errors.email.message} + )} + + + + 비밀번호 + + + + + {errors.password?.message && ( + {errors.password.message} + )} + + + + 비밀번호 확인 + + + + + {errors.passwordConfirm?.message && ( + {errors.passwordConfirm.message} + )} + + +
+ ); +} diff --git a/src/features/form-page/ui/SearchForm.tsx b/src/features/form-page/ui/SearchForm.tsx new file mode 100644 index 000000000..e1734d011 --- /dev/null +++ b/src/features/form-page/ui/SearchForm.tsx @@ -0,0 +1,39 @@ +import Image from 'next/image'; +import { FormEvent, useRef } from 'react'; +import classNames from 'classnames/bind'; + +import styles from './Form.module.scss'; +import Input from '@/src/shared/@common/ui/input'; + +const cx = classNames.bind(styles); + +export default function SearchForm() { + const searchInputRef = useRef(null); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + alert(searchInputRef.current?.value); + }; + + return ( +
+ + + 검색 돋보기 + + + + +
+ ); +} diff --git a/src/shared/@common/ui/input/Input.module.scss b/src/shared/@common/ui/input/Input.module.scss new file mode 100644 index 000000000..9a907f8f6 --- /dev/null +++ b/src/shared/@common/ui/input/Input.module.scss @@ -0,0 +1,72 @@ +@layer components { + .layout { + display: flex; + flex-direction: column; + } + + .label { + font-size: 1.25rem; + + &.hide { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); /* 구형 브라우저를 위해 사용 */ + clip-path: polygon(0 0, 0 0, 0 0); /* inset(50%) 와 동일한 표현 */ + border: 0; + } + } + + .box { + display: inline-flex; + align-items: center; + border: 0.0625rem solid #79747e; + border-radius: 0.5rem; + overflow: hidden; + width: 100%; + height: 50px; + + &:focus-within { + border-color: #5097fa; + } + + &.error { + border-color: #f00; + } + } + + .inputContainer { + display: block; + width: 100%; + height: 100%; + position: relative; + } + + .input { + display: block; + font-size: 0.75rem; + border: none; + outline: none; + width: 100%; + height: 100%; + padding-left: 0.5rem; + padding-right: 2rem; + + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + } + + .visibleToggler { + position: absolute; + right: 0.5rem; + bottom: 0.75rem; + } + + .message { + color: #f00; + } +} diff --git a/src/shared/@common/ui/input/InputBox.tsx b/src/shared/@common/ui/input/InputBox.tsx new file mode 100644 index 000000000..2a5c3d95e --- /dev/null +++ b/src/shared/@common/ui/input/InputBox.tsx @@ -0,0 +1,22 @@ +import classNames from 'classnames/bind'; +import { ReactNode } from 'react'; + +import styles from './Input.module.scss'; + +interface InputBoxProps { + children: ReactNode; + isError?: boolean; + className?: string; +} + +const cx = classNames.bind(styles); + +export default function InputBox({ + children, + isError, + className, +}: InputBoxProps) { + return ( +
{children}
+ ); +} diff --git a/src/shared/@common/ui/input/InputField.tsx b/src/shared/@common/ui/input/InputField.tsx new file mode 100644 index 000000000..238e389ae --- /dev/null +++ b/src/shared/@common/ui/input/InputField.tsx @@ -0,0 +1,85 @@ +import classNames from 'classnames/bind'; +import { + ForwardedRef, + InputHTMLAttributes, + MouseEvent, + forwardRef, + useState, +} from 'react'; +import Image, { ImageProps } from 'next/image'; + +import styles from './Input.module.scss'; + +interface InputFieldProps extends InputHTMLAttributes { + className?: string; + hasVisibleToggler?: boolean; + initialIsVisible?: boolean; + visibleTogglerAttributes?: Partial; + inVisibleTogglerAttributes?: Partial; +} + +const cx = classNames.bind(styles); + +const InputField = forwardRef( + ( + { + type: initialType, + className, + hasVisibleToggler = false, + initialIsVisible = false, + visibleTogglerAttributes, + inVisibleTogglerAttributes, + ...rest + }: InputFieldProps, + ref?: ForwardedRef, + ) => { + const [isVisible, setIsVisible] = useState(initialIsVisible); + + let type = initialType; + if (hasVisibleToggler) { + type = isVisible ? (type === 'password' ? 'text' : type) : 'password'; + } + + const handleVisibleToggler = (e: MouseEvent) => { + e.stopPropagation(); + setIsVisible((prev) => !prev); + }; + + return ( +
+ + {hasVisibleToggler && + (isVisible ? ( + 내용이 보이는 눈모양 + ) : ( + 내용이 보이지 않는 눈모양 + ))} +
+ ); + }, +); + +InputField.displayName = 'InputField'; + +export default InputField; diff --git a/src/shared/@common/ui/input/InputLabel.tsx b/src/shared/@common/ui/input/InputLabel.tsx new file mode 100644 index 000000000..f9e25f6a2 --- /dev/null +++ b/src/shared/@common/ui/input/InputLabel.tsx @@ -0,0 +1,25 @@ +import { LabelHTMLAttributes, ReactNode } from 'react'; +import classNames from 'classnames/bind'; + +import styles from './Input.module.scss'; + +interface InputLabelProps extends LabelHTMLAttributes { + children: ReactNode; + className?: string; + hide?: boolean; +} + +const cx = classNames.bind(styles); + +export default function InputLabel({ + children, + className, + hide, + ...rest +}: InputLabelProps) { + return ( + + ); +} diff --git a/src/shared/@common/ui/input/InputLayout.tsx b/src/shared/@common/ui/input/InputLayout.tsx new file mode 100644 index 000000000..81367d37e --- /dev/null +++ b/src/shared/@common/ui/input/InputLayout.tsx @@ -0,0 +1,14 @@ +import classNames from 'classnames/bind'; +import { PropsWithChildren, ReactNode } from 'react'; + +import styles from './Input.module.scss'; + +const cx = classNames.bind(styles); + +interface InputLayoutProps extends PropsWithChildren { + className?: string; +} + +export default function InputLayout({ children, className }: InputLayoutProps) { + return
{children}
; +} diff --git a/src/shared/@common/ui/input/InputMessage.tsx b/src/shared/@common/ui/input/InputMessage.tsx new file mode 100644 index 000000000..94cee2c05 --- /dev/null +++ b/src/shared/@common/ui/input/InputMessage.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from 'react'; +import classNames from 'classnames/bind'; + +import styles from './Input.module.scss'; + +interface InputMessageProps { + children: ReactNode; + className?: string; +} + +const cx = classNames.bind(styles); + +export default function InputMessage({ + children, + className, +}: InputMessageProps) { + return
{children}
; +} diff --git a/src/shared/@common/ui/input/index.tsx b/src/shared/@common/ui/input/index.tsx new file mode 100644 index 000000000..5bc9f2077 --- /dev/null +++ b/src/shared/@common/ui/input/index.tsx @@ -0,0 +1,14 @@ +import InputBox from './InputBox'; +import InputField from './InputField'; +import InputLabel from './InputLabel'; +import InputLayout from './InputLayout'; +import InputMessage from './InputMessage'; + +const Input = Object.assign(InputLayout, { + Label: InputLabel, + Box: InputBox, + Field: InputField, + Message: InputMessage, +}); + +export default Input; diff --git a/styles/@common/global.css b/styles/@common/global.css index e55f81d87..e71ad6b7e 100644 --- a/styles/@common/global.css +++ b/styles/@common/global.css @@ -1,3 +1,4 @@ +@import './layers.css'; @import './reset.css'; @import './colors.css'; @import './fonts.css'; diff --git a/styles/@common/layers.css b/styles/@common/layers.css new file mode 100644 index 000000000..ee97c1ea9 --- /dev/null +++ b/styles/@common/layers.css @@ -0,0 +1 @@ +@layer reset, base, components, overrides; diff --git a/styles/@common/reset.css b/styles/@common/reset.css index e7136f09b..614cf45ce 100644 --- a/styles/@common/reset.css +++ b/styles/@common/reset.css @@ -1,73 +1,86 @@ -/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */ -html, -body, -p, -ol, -ul, -li, -dl, -dt, -dd, -blockquote, -figure, -fieldset, -legend, -textarea, -pre, -iframe, -hr, -h1, -h2, -h3, -h4, -h5, -h6 { - margin: 0; - padding: 0; -} -h1, -h2, -h3, -h4, -h5, -h6 { - font-size: 100%; - font-weight: normal; -} -ul { - list-style: none; -} -button, -input, -select { - margin: 0; -} -html { - box-sizing: border-box; -} -*, -*::before, -*::after { - box-sizing: inherit; -} -img, -video { - height: auto; - max-width: 100%; -} -iframe { - border: 0; -} -table { - border-collapse: collapse; - border-spacing: 0; -} -td, -th { - padding: 0; -} +@layer reset { + /*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */ + html, + body, + p, + ol, + ul, + li, + dl, + dt, + dd, + blockquote, + figure, + fieldset, + legend, + textarea, + pre, + iframe, + hr, + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0; + padding: 0; + } + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: 100%; + font-weight: normal; + } + ul { + list-style: none; + } + button, + input, + select { + margin: 0; + } + html { + box-sizing: border-box; + } + *, + *::before, + *::after { + box-sizing: inherit; + } + img, + video { + height: auto; + max-width: 100%; + } + iframe { + border: 0; + } + table { + border-collapse: collapse; + border-spacing: 0; + } + td, + th { + padding: 0; + } + + /* custom */ + a { + text-decoration: none; + } + + input { + border: none; + outline: none; + padding: 0; -/* custom */ -a { - text-decoration: none; + &:-webkit-autofill { + -webkit-box-shadow: 0 0 0 1000px #ffffff inset; + box-shadow: 0 0 0 1000px #ffffff inset; + } + } } diff --git a/styles/pages/Home.module.css b/styles/pages/Home.module.css index 5a755b73e..01f978611 100644 --- a/styles/pages/Home.module.css +++ b/styles/pages/Home.module.css @@ -1,5 +1,4 @@ .landing { - text-align: center; margin: 50px auto; height: 1000px; } @@ -9,3 +8,10 @@ justify-content: center; gap: 16px; } + +.formPage { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; +}