diff --git a/src/components/Dropdown/Dropdown.module.scss b/src/components/Dropdown/Dropdown.module.scss index 9a67f90..25475fd 100644 --- a/src/components/Dropdown/Dropdown.module.scss +++ b/src/components/Dropdown/Dropdown.module.scss @@ -68,6 +68,7 @@ border-radius: 8px; margin-top: 8px; box-shadow: 0px 2px 12px 0px #00000014; + z-index: 100; } &__list-item { diff --git a/src/components/Editor/Editor.jsx b/src/components/Editor/Editor.jsx index 3f4afce..45582ab 100644 --- a/src/components/Editor/Editor.jsx +++ b/src/components/Editor/Editor.jsx @@ -42,12 +42,14 @@ function OnEditorChange({ onUpdate }) { * @param {boolean} [readOnly=false] - 읽기 전용 모드 여부 */ export default function Editor({ + inputId = '', content = '', onUpdate = () => {}, style = {}, readOnly = false, font = 'Pretendard', className = '', + onBlur, }) { // 1) theme: 텍스트 포맷 → CSS 클래스 매핑 const theme = { @@ -102,6 +104,8 @@ export default function Editor({ } placeholder={ diff --git a/src/components/Editor/Editor.module.scss b/src/components/Editor/Editor.module.scss index 74914c6..29ad5ff 100644 --- a/src/components/Editor/Editor.module.scss +++ b/src/components/Editor/Editor.module.scss @@ -2,12 +2,15 @@ .editor__content { /* 에디터 입력 영역(콘텐츠Editable) 스타일 */ + position: relative; min-height: 260px; padding: 12px; outline: none; font-family: inherit !important; /* 부모에서 상속받은 폰트 사용 */ font-size: var(--font-size-16); color: var(--color-gray-900); + max-height: 400px; + overflow-y: auto; /* 롤링페이퍼 폰트는 부모에서 전달받음 */ /* 혹시 모를 리셋(reset.css) 덮어쓰기 방지 */ @@ -21,6 +24,9 @@ } .editor__placeholder { + position: absolute; + top: 50px; + left: 5px; /* 내용이 비어 있을 때 보여줄 플레이스홀더 문구 스타일 */ color: var(--color-gray-400); padding: 12px; diff --git a/src/components/PostHeader/EmojiGroup/EmojiAdd.jsx b/src/components/PostHeader/EmojiGroup/EmojiAdd.jsx index bc994b7..d04bb5a 100644 --- a/src/components/PostHeader/EmojiGroup/EmojiAdd.jsx +++ b/src/components/PostHeader/EmojiGroup/EmojiAdd.jsx @@ -67,7 +67,7 @@ export default function EmojiAdd({ id, onSuccess, isMobile = false }) { * @todo IconButton 컴포넌트로 변경 예정 */ const toggleButton = isMobile ? ( - + ) : ( { try { const currentUrl = window.location.href; @@ -40,7 +44,9 @@ export default function ShareMenu({ onKakaoClick }) { }, [showToast]); // 토글 버튼(Share 아이콘) - const toggleButton = ( + const toggleButton = isMobile ? ( + + ) : ( ); diff --git a/src/components/Textfield.module.scss b/src/components/Textfield.module.scss index 7c56380..bf91850 100644 --- a/src/components/Textfield.module.scss +++ b/src/components/Textfield.module.scss @@ -8,6 +8,7 @@ font-size: var(--font-size-16); font-weight: var(--font-weight-regular); color: var(--color-gray-500); + transition: border-color 0.15s ease; &:hover { border: 1px solid var(--color-purple-600); @@ -16,7 +17,7 @@ &:focus, &:active { - border: 2px solid var(--color-purple-600); + border: 1px solid var(--color-purple-600); color: var(--color-gray-900); } @@ -37,7 +38,7 @@ &:focus, &:hover { - border: 1px solid var(--color-error); + border-color: var(--color-error); } } @@ -51,15 +52,16 @@ &:focus, &:hover { - border: 1px solid var(--color-green-500); + border-color: var(--color-green-500); } } } .textfield__message { + bottom: 0; font-size: var(--font-size-12); margin-top: 4px; - margin-left: 10px; + margin-left: 10px; &--error { color: var(--color-error); } diff --git a/src/hooks/useForm.jsx b/src/hooks/useForm.jsx index ac52486..7daa3bd 100644 --- a/src/hooks/useForm.jsx +++ b/src/hooks/useForm.jsx @@ -1,82 +1,104 @@ // src/hooks/useForm.jsx -import { useState, useCallback, useMemo } from 'react'; +import { useState } from 'react'; /** - * 폼 상태와 검증을 관리하는 커스텀 훅 + * 범용 폼 훅 – 모든 검증 규칙은 호출 측에서 주입 * - * @param {Object} initialValues - * @param {Object.boolean>} [customValidationRules] + * @template V + * @param {V} initialValues 초기 필드 값 + * @param {Object.boolean, message:string}>>} validationRules + * 필드별 검증 규칙(배열). 규칙이 없으면 해당 필드는 항상 통과 * * @returns {{ - * values: Object, - * validity: Object, // 필드별 유효성 - * touched: Object, // 필드별 입력 여부 - * handleChange: (field:string)=>(v:any)=>void, - * handleBlur: (field:string)=>() => void, // blur 전용 - * resetForm: () => void, - * isFormValid: boolean + * values: V, + * errorMessages: Record, + * fieldValidity: Record, // null=미검증, true=통과, false=실패 + * handleChange: (field:keyof V)=>(value:any)=>void, + * handleBlur: (field:keyof V)=>() => void, + * resetForm: () => void, + * isFormValid: boolean * }} */ -export const useForm = (initialValues = {}, customValidationRules = {}) => { - /* 1) 값 관리 */ - const [values, setValues] = useState(initialValues); +export const useForm = (initialValues = {}, validationRules = {}) => { + const fieldNames = Object.keys(initialValues); - /* 2) 초기 유효성 계산 */ - const initValidity = useMemo(() => { - const validate = (k, v) => - typeof customValidationRules[k] === 'function' - ? customValidationRules[k](v) - : typeof v === 'string' - ? v.trim() !== '' - : v != null; + // 다른 필드 값(allValue)들을 포함한 유효성 검사 -> 첫 번째 실패 메시지 또는 null + // 다른 필드의 값이 필요할 때는 allValues를 통해 접근 + const validateField = (fieldName, value, allValues) => { + const rulesForField = validationRules[fieldName] ?? []; + const failedRule = rulesForField.find((rule) => !rule.test(value, allValues)); + return failedRule ? failedRule.message : null; + }; + /* ------------------------------- 상태 관리 ------------------------------- */ + const [values, setValues] = useState(initialValues); - return Object.fromEntries(Object.entries(initialValues).map(([k, v]) => [k, validate(k, v)])); - }, [initialValues, customValidationRules]); - const [validity, setValidity] = useState(initValidity); + // 초기값으로 각 필드의 유효성 검사 결과를 설정 + const [errorMessages, setErrorMessages] = useState(() => { + const initialMessage = {}; + for (const field in initialValues) { + initialMessage[field] = validateField(field, initialValues[field], initialValues); + } + return initialMessage; + }); - /* 3) touched 상태 */ - const [touched, setTouched] = useState( - Object.fromEntries(Object.keys(initialValues).map((k) => [k, false])), + // 한번이라도 입력된 필드인지 여부 + // 초기값으로 필드별 touched 상태를 false로 설정 + const [touchedFields, setTouchedFields] = useState(() => + Object.fromEntries(fieldNames.map((name) => [name, false])), ); - /* 4) 값 & 유효성 & touched 업데이트 */ - const handleChange = useCallback( - (field) => (newValue) => { - setValues((p) => ({ ...p, [field]: newValue })); - setTouched((p) => ({ ...p, [field]: true })); - - const isValid = - typeof customValidationRules[field] === 'function' - ? customValidationRules[field](newValue) - : typeof newValue === 'string' - ? newValue.trim() !== '' - : newValue != null; + // 필드가 아직 건드리지 않았으면 null, 통과하면 true, 에러면 false + const getFieldValidity = (fieldName) => { + if (!touchedFields[fieldName]) { + return null; // 아직 건드리지 않음 + } + if (errorMessages[fieldName] === null) { + return true; // 통과 + } + return false; // 에러 + }; - setValidity((p) => ({ ...p, [field]: isValid })); - }, - [customValidationRules], + // 각 필드의 유효성 검사 결과를 객체로 반환 + const fieldValidity = Object.fromEntries( + fieldNames.map((name) => [name, getFieldValidity(name)]), ); - /* blur 만으로 touched 표시하고 싶을 때 */ - const handleBlur = useCallback((field) => () => setTouched((p) => ({ ...p, [field]: true })), []); + // 전체 폼이 유효한지 여부 + const isFormValid = fieldNames.every((name) => errorMessages[name] === null); - /* 5) 초기화 */ - const resetForm = useCallback(() => { - setValues(initialValues); - setValidity(initValidity); - setTouched(Object.fromEntries(Object.keys(initialValues).map((k) => [k, false]))); - }, [initialValues, initValidity]); + /* -------------------------------------------- handlers */ + const handleChange = (fieldName) => (newValue) => { + setValues((previous) => { + const next = { ...previous, [fieldName]: newValue }; + setErrorMessages((previousErrors) => ({ + ...previousErrors, + [fieldName]: validateField(fieldName, newValue, next), + })); + return next; + }); + }; + + const handleBlur = (fieldName) => () => + setTouchedFields((prev) => ({ ...prev, [fieldName]: true })); - /* 6) 폼 전체 유효성 */ - const isFormValid = useMemo(() => Object.values(validity).every(Boolean), [validity]); + const resetForm = () => { + setValues(initialValues); + setTouchedFields(Object.fromEntries(fieldNames.map((name) => [name, false]))); + setErrorMessages(() => + Object.fromEntries( + fieldNames.map((name) => [name, validateField(name, initialValues[name], initialValues)]), + ), + ); + }; + /* ------------------------------------------------ return */ return { values, - validity, // ➕ - touched, // ➕ + errorMessages, + fieldValidity, handleChange, - handleBlur, // ➕ + handleBlur, resetForm, isFormValid, }; diff --git a/src/pages/MessagePage/MessagePage.jsx b/src/pages/MessagePage/MessagePage.jsx index f48de4d..6d20bf7 100644 --- a/src/pages/MessagePage/MessagePage.jsx +++ b/src/pages/MessagePage/MessagePage.jsx @@ -9,15 +9,15 @@ import Textfield from '@/components/Textfield'; import Dropdown from '@/components/Dropdown/Dropdown'; import ProfileSelector from './components/ProfileSelector'; import styles from './MessagePage.module.scss'; -import Editor from '@/components/Editor/Editor'; import { FONT_OPTIONS, FONT_DROPDOWN_ITEMS } from '@/constants/fontMap'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; +import Button from '@/components/Button/Button'; +import EditorWrapper from '@/pages/MessagePage/components/EditorWrapper'; // 상대와의 관계 옵션 const RELATIONSHIP_OPTIONS = ['친구', '지인', '동료', '가족']; function MessagePage() { - const [fontDropdownOpen, setFontDropdownOpen] = useState(false); const { showToast } = useToast(); const navigate = useNavigate(); const { id: recipientId } = useParams(); @@ -30,22 +30,27 @@ function MessagePage() { content: '', font: FONT_OPTIONS[0], }; - - // content 필드만 HTML 태그 제거 후 텍스트 길이 > 0 체크 + // 유효성 검사 규칙 정의 const validationRules = { - content: (html) => { - const div = document.createElement('div'); - div.innerHTML = html; - // 같은 비문자 공백도 제거 - const text = div.textContent.replace(/\u00a0/g, '').trim(); - return text.length > 0; - }, + sender: [ + { test: (v) => v.trim() !== '', message: '이름은 필수입니다.' }, + { test: (v) => v.trim().length <= 20, message: '이름은 20자 이내입니다.' }, + ], + content: [ + { + test: (html) => { + const div = document.createElement('div'); + div.innerHTML = html; + const text = div.textContent.replace(/\u00a0/g, '').trim(); + return text.length > 0; + }, + message: '내용은 필수입니다.', + }, + ], }; // useForm 훅으로 모든 필드(특히 content) 값을 관리 - const { values, validity, touched, handleChange, handleBlur, resetForm, isFormValid } = useForm( - initialValues, - validationRules, - ); + const { values, errorMessages, fieldValidity, handleChange, handleBlur, resetForm, isFormValid } = + useForm(initialValues, validationRules); // 메시지 생성 API 호출을 위한 useApi 훅 사용 const { data, loading, refetch } = useApi(createRecipientMessage, null, { @@ -87,24 +92,27 @@ function MessagePage() { {/* 1) From (이름 입력) */} - + From. {/* 2) 프로필 이미지 선택 */} - 프로필 이미지 - + + 프로필 이미지 + + {/* 3) 상대와의 관계 (select) */} @@ -113,6 +121,7 @@ function MessagePage() { 상대와의 관계 - {/* 4) 내용 (laxical Editor) */} - - 내용을 입력해 주세요 - - {/* - Editor 컴포넌트에 content(HTML)와 onUpdate 콜백을 전달: - onUpdate(html) → handleChange('content')(html) 형태로 폼 값이 갱신됩니다. - */} - - - - - {/* 5) 폰트 선택 (select) */} + {/* 4) 폰트 선택 (select) */} 폰트 선택 + + + {/* 5) 내용 (laxical Editor) */} + + + 내용을 입력해 주세요 + + {/* 6) 전송 버튼 */} - {fontDropdownOpen && } - 생성하기 - + diff --git a/src/pages/MessagePage/MessagePage.module.scss b/src/pages/MessagePage/MessagePage.module.scss index 4599e24..075e190 100644 --- a/src/pages/MessagePage/MessagePage.module.scss +++ b/src/pages/MessagePage/MessagePage.module.scss @@ -33,17 +33,6 @@ margin-top: 0.25rem; } - /* 에디터 래퍼 */ - &__editor-wrapper { - border: 1px solid var(--color-gray-300); - border-radius: 8px; - max-height: 500px; - } - &__editor { - width: 100%; - height: 100%; - } - /* 전송 버튼 영역 */ &__actions { display: flex; diff --git a/src/pages/MessagePage/components/EditorWrapper.jsx b/src/pages/MessagePage/components/EditorWrapper.jsx new file mode 100644 index 0000000..0b9b222 --- /dev/null +++ b/src/pages/MessagePage/components/EditorWrapper.jsx @@ -0,0 +1,64 @@ +// src/pages/MessagePage/components/EditorWrapper.jsx +import styles from './EditorWrapper.module.scss'; +import Editor from '@/components/Editor/Editor'; + +const EditorWrapper = ({ + inputId = '', + value, + onChange, + onBlur, + isValid = null, // null | true | false + message, + font, + className = '', +}) => { + /* 상태별 클래스 계산 */ + const wrapperClass = { + null: '', + false: 'editor-wrapper--error', + true: 'editor-wrapper--success', + }; + + const messageStateClass = { + null: '', + false: 'editor-wrapper__message--error', + true: 'editor-wrapper__message--success', + }; + + const handleUpdate = (html) => onChange(html); + + const showMessage = message && isValid === false; + + return ( + + + + + + {showMessage && ( + + {message} + + )} + + ); +}; + +export default EditorWrapper; diff --git a/src/pages/MessagePage/components/EditorWrapper.module.scss b/src/pages/MessagePage/components/EditorWrapper.module.scss new file mode 100644 index 0000000..e3b8b89 --- /dev/null +++ b/src/pages/MessagePage/components/EditorWrapper.module.scss @@ -0,0 +1,45 @@ +/* src/pages/MessagePage/components/EditorWrapper.module.scss */ +.editor-wrapper__container { + position: relative; + width: 100%; +} + +.editor-wrapper { + max-height: 500px; + display: flex; + flex-direction: column; + width: 100%; + + border: 1px solid var(--color-gray-300); + border-radius: 8px; + transition: border-color 0.15s ease; + + &:focus-within { + border-color: var(--color-purple-600); + } + /* 오류 & 성공 상태 */ + &--error { + border-color: var(--color-error); + } + &--success { + border-color: var(--color-green-500); + } + &__content { + flex: 1; + max-height: inherit; + } + + &__message { + position: absolute; + bottom: -18px; + font-size: var(--font-size-12); + margin: 4px 0 0 10px; + + &--error { + color: var(--color-error); + } + &--success { + color: var(--color-green-500); + } + } +}