diff --git a/index.html b/index.html index 3c20db6..e57d567 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,7 @@
+ diff --git a/package-lock.json b/package-lock.json index 74a5db7..a13efef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "axios": "^1.11.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-router": "^7.8.0", "styled-components": "^6.1.19" }, "devDependencies": { @@ -1683,6 +1684,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2815,6 +2825,28 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz", + "integrity": "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2881,6 +2913,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", diff --git a/package.json b/package.json index 812f44f..72906b1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "axios": "^1.11.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-router": "^7.8.0", "styled-components": "^6.1.19" }, "devDependencies": { diff --git a/src/app.jsx b/src/app.jsx index ee2bf95..7fb23d8 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -1,9 +1,19 @@ -// import TestPage from "./pages/test-page"; +import { BrowserRouter, Route, Routes } from "react-router"; +import DropdownProvider from "./components/text-field/dropdown-input/dropdown-provider"; import MessagePage from "./pages/message-list"; +import TestPage from "./pages/test-page"; function App() { - // return ; - return ; + return ( + + + + } /> + } /> + + + + ); } export default App; diff --git a/src/assets/ic-chevron-down.svg b/src/assets/ic-chevron-down.svg new file mode 100644 index 0000000..412d030 --- /dev/null +++ b/src/assets/ic-chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/ic-chevron-up.svg b/src/assets/ic-chevron-up.svg new file mode 100644 index 0000000..536315f --- /dev/null +++ b/src/assets/ic-chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/text-field/dropdown-input/dropdown-context.js b/src/components/text-field/dropdown-input/dropdown-context.js new file mode 100644 index 0000000..34a3d95 --- /dev/null +++ b/src/components/text-field/dropdown-input/dropdown-context.js @@ -0,0 +1,5 @@ +import { createContext } from "react"; + +const DropdownContext = createContext(null); + +export default DropdownContext; diff --git a/src/components/text-field/dropdown-input/dropdown-input.jsx b/src/components/text-field/dropdown-input/dropdown-input.jsx new file mode 100644 index 0000000..09c937b --- /dev/null +++ b/src/components/text-field/dropdown-input/dropdown-input.jsx @@ -0,0 +1,145 @@ +import styled from "styled-components"; +import arrowDownImg from "../../../assets/ic-chevron-down.svg"; +import arrowUpImg from "../../../assets/ic-chevron-up.svg"; +import { useDropdown } from "../../../hooks/dropdown/use-dropdown"; +import INPUT_STYLES from "../input-styles"; +import Dropdown from "./dropdown"; +import DropdownOption from "./dropdown-option"; + +const PlaceholderText = styled.span` + ${INPUT_STYLES.font} + color: ${INPUT_STYLES.textColor.placeholder}; + flex-grow: 1; + text-align: left; +`; + +const InputText = styled.span` + ${INPUT_STYLES.font} + color: ${INPUT_STYLES.textColor.normal}; + flex-grow: 1; + text-align: left; +`; + +function Text({ value, placeholder }) { + return value ? ( + {value} + ) : ( + {placeholder} + ); +} + +const Icon = styled.div` + width: 16px; + height: 16px; + + img { + width: 100%; + height: 100%; + } +`; + +const StyledDropdownInput = styled.button` + background-color: ${INPUT_STYLES.backgroundColor.normal}; + border: none; + border-radius: 8px; + box-shadow: 0 0 0 1px + ${({ $error }) => INPUT_STYLES.borderColor.normal($error)} inset; + padding: 12px 16px; + min-width: 320px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + position: relative; + + &:hover { + box-shadow: 0 0 0 1px + ${({ $error }) => INPUT_STYLES.borderColor.hover($error)} inset; + } + + &:active { + box-shadow: 0 0 0 2px + ${({ $error }) => INPUT_STYLES.borderColor.active($error)} inset; + } + + &:focus { + box-shadow: 0 0 0 2px + ${({ $error }) => INPUT_STYLES.borderColor.focus($error)} inset; + } + + &:disabled { + background-color: ${INPUT_STYLES.backgroundColor.disabled}; + box-shadow: 0 0 0 1px ${INPUT_STYLES.borderColor.disabled} inset; + cursor: default; + } +`; + +function DropdownInput({ + dropdownId, + error, + placeholder, + value, + options, + onSelect, + ...props +}) { + const { + targetRef, + dropdownRect, + showsDropdown, + setShowsDropdown, + handleTargetClick, + } = useDropdown({ + id: dropdownId, + type: "dropdown-input", + }); + + const handleInputClick = () => { + handleTargetClick(!showsDropdown); + }; + + const handleOptionClick = (event) => { + onSelect(event.target.textContent); + }; + + const handleDropdownClose = () => { + setShowsDropdown(false); + }; + + return ( + <> + + + + Dropdown 화살표 + + {showsDropdown && ( + + {options.map((option, index) => ( + + {option} + + ))} + + )} + + + ); +} + +export default DropdownInput; diff --git a/src/components/text-field/dropdown-input/dropdown-option.jsx b/src/components/text-field/dropdown-input/dropdown-option.jsx new file mode 100644 index 0000000..6619019 --- /dev/null +++ b/src/components/text-field/dropdown-input/dropdown-option.jsx @@ -0,0 +1,20 @@ +import styled from "styled-components"; +import Colors from "../../color/colors"; + +const DropdownOption = styled.div` + width: calc(100% - 2px); + border: none; + background: none; + padding: 12px 16px; + font-size: 16px; + font-weight: 400; + line-height: 26px; + color: ${Colors.gray(900)}; + cursor: pointer; + + &:hover { + background-color: ${Colors.gray(100)}; + } +`; + +export default DropdownOption; diff --git a/src/components/text-field/dropdown-input/dropdown-provider.jsx b/src/components/text-field/dropdown-input/dropdown-provider.jsx new file mode 100644 index 0000000..f74b577 --- /dev/null +++ b/src/components/text-field/dropdown-input/dropdown-provider.jsx @@ -0,0 +1,10 @@ +import { useState } from "react"; +import DropdownContext from "./dropdown-context"; + +function DropdownProvider({ children }) { + const [dropdownState, setDropdownState] = useState({}); + const value = { dropdownState, setDropdownState }; + return {children}; +} + +export default DropdownProvider; diff --git a/src/components/text-field/dropdown-input/dropdown.jsx b/src/components/text-field/dropdown-input/dropdown.jsx new file mode 100644 index 0000000..188df8a --- /dev/null +++ b/src/components/text-field/dropdown-input/dropdown.jsx @@ -0,0 +1,66 @@ +import { createPortal } from "react-dom"; +import styled from "styled-components"; +import Colors from "../../color/colors"; + +const BACKDROP_CLASS_NAME = "dropdown-backdrop"; + +const DropdownContainer = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 111; + + & > *:not(.${BACKDROP_CLASS_NAME}) { + z-index: 114; + } +`; + +const DropdownBackdrop = styled(DropdownContainer)` + z-index: 112; + position: fixed; +`; + +const DropdownContent = styled(DropdownContainer)` + z-index: 113; + position: relative; + height: 100%; +`; + +const StyledDropdown = styled.div` + background-color: white; + box-shadow: 0 0 0 1px ${Colors.gray(300)} inset, + 0 2px 12px 0 rgba(0, 0, 0, 0.08); + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 0; + position: absolute; + top: ${({ $origin }) => $origin.y}px; + left: ${({ $origin }) => $origin.x}px; + width: ${({ $size }) => $size.width}px; +`; + +function Dropdown({ children, origin, size, onClose }) { + const DropdownPortal = ({ children }) => { + return createPortal(children, document.getElementById("dropdown")); + }; + + return ( + + + + + + {children} + + + + + + ); +} + +export default Dropdown; diff --git a/src/components/text-field/input-styles.js b/src/components/text-field/input-styles.js new file mode 100644 index 0000000..24b7b4c --- /dev/null +++ b/src/components/text-field/input-styles.js @@ -0,0 +1,27 @@ +import { css } from "styled-components"; +import Colors from "../color/colors"; + +const INPUT_STYLES = Object.freeze({ + font: css` + font-size: 16px; + font-weight: 400; + line-height: 26px; + `, + borderColor: { + normal: (error) => (error ? Colors.error : Colors.gray(300)), + hover: (error) => (error ? Colors.error : Colors.gray(500)), + active: (error) => (error ? Colors.error : Colors.gray(700)), + focus: (error) => (error ? Colors.error : Colors.gray(500)), + disabled: Colors.gray(300), + }, + textColor: { + normal: Colors.gray(900), + placeholder: Colors.gray(500), + }, + backgroundColor: { + normal: "#ffffff", + disabled: Colors.gray(100), + }, +}); + +export default INPUT_STYLES; diff --git a/src/components/text-field/text-field-type.js b/src/components/text-field/text-field-type.js new file mode 100644 index 0000000..80da361 --- /dev/null +++ b/src/components/text-field/text-field-type.js @@ -0,0 +1,6 @@ +const TEXT_FIELD_TYPE = Object.freeze({ + input: "input", + dropdown: "dropdown", +}); + +export default TEXT_FIELD_TYPE; diff --git a/src/components/text-field/text-field.jsx b/src/components/text-field/text-field.jsx new file mode 100644 index 0000000..c97832d --- /dev/null +++ b/src/components/text-field/text-field.jsx @@ -0,0 +1,34 @@ +import styled from "styled-components"; +import Colors from "../color/colors"; +import DropdownInput from "./dropdown-input/dropdown-input"; +import TEXT_FIELD_TYPE from "./text-field-type"; +import TextInput from "./text-input/text-input"; + +const StyledInputTextField = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +const ErrorMessage = styled.p` + margin: 0; + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: ${Colors.error}; +`; + +function InputTextField({ type, error, dropdownId, ...props }) { + return ( + + {type === TEXT_FIELD_TYPE.input ? ( + + ) : ( + + )} + {error && {error}} + + ); +} + +export default InputTextField; diff --git a/src/components/text-field/text-input/text-input.jsx b/src/components/text-field/text-input/text-input.jsx new file mode 100644 index 0000000..5687c6e --- /dev/null +++ b/src/components/text-field/text-input/text-input.jsx @@ -0,0 +1,46 @@ +import styled from "styled-components"; +import INPUT_STYLES from "../input-styles"; + +const StyledTextInput = styled.input` + background-color: ${INPUT_STYLES.backgroundColor.normal}; + outline: none; + border: none; + border-radius: 8px; + box-shadow: 0 0 0 1px + ${({ $error }) => INPUT_STYLES.borderColor.normal($error)} inset; + padding: 12px 16px; + ${INPUT_STYLES.font} + color: ${INPUT_STYLES.textColor.normal}; + min-width: 320px; + + &::placeholder { + ${INPUT_STYLES.font} + color: ${INPUT_STYLES.textColor.placeholder}; + } + + &:hover { + box-shadow: 0 0 0 1px + ${({ $error }) => INPUT_STYLES.borderColor.hover($error)} inset; + } + + &:active { + box-shadow: 0 0 0 2px + ${({ $error }) => INPUT_STYLES.borderColor.active($error)} inset; + } + + &:focus { + box-shadow: 0 0 0 2px + ${({ $error }) => INPUT_STYLES.borderColor.focus($error)} inset; + } + + &:disabled { + background-color: ${INPUT_STYLES.backgroundColor.disabled}; + box-shadow: 0 0 0 1px ${INPUT_STYLES.borderColor.disabled} inset; + } +`; + +function TextInput({ error, ...props }) { + return ; +} + +export default TextInput; diff --git a/src/hooks/dropdown/use-dropdown.jsx b/src/hooks/dropdown/use-dropdown.jsx new file mode 100644 index 0000000..cdc06af --- /dev/null +++ b/src/hooks/dropdown/use-dropdown.jsx @@ -0,0 +1,54 @@ +import { useContext, useRef, useState } from "react"; +import DropdownContext from "../../components/text-field/dropdown-input/dropdown-context"; + +function makeRect({ x, y, width } = { x: 0, y: 0, width: 0 }) { + return { + origin: { x, y }, + size: { width }, + }; +} + +function calculateDropdownRect(target) { + if (!target) { + return makeRect(); + } + + const targetRect = target.getBoundingClientRect(); + const dropdownRect = makeRect({ + x: targetRect.left, + y: targetRect.bottom + 8, + width: targetRect.width, + }); + + return dropdownRect; +} + +function useDropdown({ id, type }) { + const { dropdownState, setDropdownState } = useContext(DropdownContext); + const [dropdownRect, setDropdownRect] = useState(); + + const targetRef = useRef(); + + const key = `${type}_${id}`; + const showsDropdown = dropdownState[key] ?? false; + + const setShowsDropdown = (shows) => { + setDropdownState((prev) => ({ ...prev, [key]: shows })); + }; + + const handleTargetClick = (shows) => { + const rect = calculateDropdownRect(targetRef.current); + setShowsDropdown(shows); + setDropdownRect(rect); + }; + + return { + targetRef, + dropdownRect, + showsDropdown, + setShowsDropdown, + handleTargetClick, + }; +} + +export { useDropdown }; diff --git a/src/pages/test-page.jsx b/src/pages/test-page.jsx index d17b7f1..2ae1ed2 100644 --- a/src/pages/test-page.jsx +++ b/src/pages/test-page.jsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import smileAddImg from "../assets/ic-face-smile-add.svg"; import Badge from "../components/badge/badge"; import BADGE_TYPE from "../components/badge/badge-type"; @@ -11,8 +12,21 @@ import { } from "../components/button/button"; import BUTTON_SIZE from "../components/button/button-size"; import ToggleButton from "../components/button/toggle-button"; +import TextField from "../components/text-field/text-field"; +import TEXT_FIELD_TYPE from "../components/text-field/text-field-type"; function TestPage() { + const [option1, setOption1] = useState(); + const [option2, setOption2] = useState(); + const [dropdown2Error, setDropdown2Error] = useState("Error Message"); + const handleDropdownSelect1 = (option) => { + setOption1(option); + }; + const handleDropdownSelect2 = (option) => { + setOption2(option); + setDropdown2Error(null); + }; + return (
+
+ + + +
+
+ + + +
); }