diff --git a/src/common/component/MandalartTextField/MandalartTextField.css.ts b/src/common/component/MandalartTextField/MandalartTextField.css.ts deleted file mode 100644 index ffea2e9f..00000000 --- a/src/common/component/MandalartTextField/MandalartTextField.css.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { style, styleVariants } from '@vanilla-extract/css'; - -import { colors } from '@/style/token/color.css'; -import { fonts } from '@/style/token/typography.css'; - -// ====== 공통 base 스타일 ====== -const bigGoalBase = { - display: 'flex', - alignItems: 'center', - flexShrink: 0, - width: '57.1rem', - height: '8rem', - borderRadius: '12px', - padding: '2rem 3rem', - fontSize: fonts.title01.fontSize, - fontWeight: fonts.title01.fontWeight, - lineHeight: fonts.title01.lineHeight, - color: colors.grey10, -}; -const subGoalBase = { - display: 'flex', - alignItems: 'center', - flexShrink: 0, - width: '57.1rem', - height: '5.6rem', - borderRadius: '8px', - padding: '1.4rem 2rem', - fontSize: fonts.subtitle03.fontSize, - fontWeight: fonts.subtitle03.fontWeight, - lineHeight: fonts.subtitle03.lineHeight, - color: colors.grey10, -}; -const todoBase = { - display: 'flex', - alignItems: 'center', - flexShrink: 0, - width: '43.6rem', - height: '5.6rem', - borderRadius: '8px', - padding: '1.4rem 2rem', - fontSize: fonts.subtitle03.fontSize, - fontWeight: fonts.subtitle03.fontWeight, - lineHeight: fonts.subtitle03.lineHeight, - color: '#111111', -}; - -export const bigGoalBaseClass = style(bigGoalBase); -export const subGoalBaseClass = style(subGoalBase); -export const todoBaseClass = style(todoBase); - -export const inputBase = style({ - display: 'block', - width: '100%', - height: '100%', - background: 'transparent', - border: 'none', - outline: 'none', - padding: 0, - color: colors.grey10, -}); - -const makeInputStyle = (font: Record) => - style({ - fontFamily: 'Pretendard', - ...font, - '::placeholder': { - color: colors.grey6, - fontFamily: 'Pretendard', - ...font, - }, - }); -export const inputBigGoal = makeInputStyle(fonts.title01); -export const inputSubGoal = makeInputStyle(fonts.subtitle03); -export const inputTodo = makeInputStyle(fonts.subtitle03); - -export const inputVariants = styleVariants({ - bigGoal: [inputBase, inputBigGoal], - subGoal: [inputBase, inputSubGoal], - todo: [inputBase, inputTodo], -}); - -export const bigGoalVariants = styleVariants({ - default: { - ...bigGoalBase, - border: '0.3rem solid transparent', - background: colors.grey4, - color: colors.grey6, - textAlign: 'left', - }, - clicked: { - ...bigGoalBase, - border: `0.3rem solid ${colors.grey5}`, - background: colors.grey3, - color: colors.grey6, - textAlign: 'left', - }, - typing: { - ...bigGoalBase, - border: `0.3rem solid ${colors.grey5}`, - background: colors.grey3, - color: colors.grey10, - textAlign: 'left', - justifyContent: 'space-between', - }, - filled: { - ...bigGoalBase, - border: '0.3rem solid transparent', - background: colors.grey4, - color: colors.grey10, - textAlign: 'center', - }, - hover: { - ...bigGoalBase, - border: '0.3rem solid transparent', - background: colors.grey3, - color: colors.grey6, - textAlign: 'center', - }, -}); - -const createVariantStyles = (baseStyle: object) => ({ - default: { - ...baseStyle, - border: '2px solid transparent', - background: colors.grey4, - color: colors.grey6, - textAlign: 'center' as const, - }, - clicked: { - ...baseStyle, - border: `2px solid ${colors.blue06}`, - background: colors.grey3, - color: colors.grey6, - textAlign: 'center' as const, - }, - typing: { - ...baseStyle, - border: `2px solid ${colors.blue06}`, - background: colors.grey3, - color: colors.grey10, - textAlign: 'left' as const, - justifyContent: 'space-between' as const, - }, - filled: { - ...baseStyle, - border: '2px solid transparent', - background: colors.grey4, - color: colors.grey10, - textAlign: 'center' as const, - fontWeight: 600, - }, - hover: { - ...baseStyle, - border: '2px solid transparent', - background: colors.grey3, - color: colors.grey6, - textAlign: 'center' as const, - }, -}); - -export const subGoalVariants = styleVariants(createVariantStyles(subGoalBase)); -export const todoVariants = styleVariants(createVariantStyles(todoBase)); - -const CLEAR_BUTTON_SIZE = '3.2rem'; -const CLEAR_BUTTON_SMALL_SIZE = '2.4rem'; -export const clearButton = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: CLEAR_BUTTON_SIZE, - height: CLEAR_BUTTON_SIZE, - aspectRatio: '1/1', - background: 'none', - border: 'none', - padding: 0, - cursor: 'pointer', -}); -export const clearButtonSmall = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: CLEAR_BUTTON_SMALL_SIZE, - height: CLEAR_BUTTON_SMALL_SIZE, - background: 'none', - border: 'none', - padding: 0, - cursor: 'pointer', -}); diff --git a/src/common/component/MandalartTextField/MandalartTextField.stories.tsx b/src/common/component/MandalartTextField/MandalartTextField.stories.tsx deleted file mode 100644 index 247f8bda..00000000 --- a/src/common/component/MandalartTextField/MandalartTextField.stories.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useState } from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import MandalartTextField from './MandalartTextField'; - -const meta: Meta = { - title: 'Common/MandalartTextField', - component: MandalartTextField, - parameters: { - layout: 'centered', - docs: { - description: { - component: - '상위목표(bigGoal), 하위목표(subGoal), 할 일(todo) 등 다양한 텍스트필드 용도로 사용할 수 있습니다. variant별로 스타일/placeholder/최대글자수 등이 자동 적용됩니다.', - }, - }, - }, - tags: ['autodocs'], - argTypes: { - variant: { - control: 'radio', - options: ['bigGoal', 'subGoal', 'todo'], - description: '텍스트필드 타입 (상위목표/하위목표/할 일)', - }, - value: { control: 'text', description: '입력값' }, - placeholder: { control: 'text', description: 'placeholder (미입력 시 자동 적용)' }, - maxLength: { control: 'number', description: '최대 글자수 (상위목표만 30자 제한)' }, - disabled: { control: 'boolean', description: '비활성화 여부' }, - onChange: { action: 'changed', description: '입력값 변경 이벤트' }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Interactive: Story = { - args: { - variant: 'bigGoal', - value: '', - onChange: () => {}, - placeholder: '', - maxLength: 30, - disabled: false, - }, - render: (args) => { - const [value, setValue] = useState(''); - return ; - }, - parameters: { - docs: { - description: { - story: '실제 입력/클리어/포커스 등 모든 상태를 직접 테스트할 수 있습니다.', - }, - }, - }, -}; - -const variants = [ - { variant: 'bigGoal', label: '상위목표 (bigGoal)' }, - { variant: 'subGoal', label: '하위목표 (subGoal)' }, - { variant: 'todo', label: '할 일 (todo)' }, -] as const; - -export const Variants: Story = { - render: () => { - const [values, setValues] = useState(['', '', '']); - return ( -
- {variants.map(({ variant, label }, idx) => ( -
-
{label}
- setValues((vals) => vals.map((val, i) => (i === idx ? v : val)))} - /> -
- ))} -
- ); - }, - parameters: { - docs: { - description: { - story: 'variant별 스타일/placeholder/최대글자수 자동 적용 예시입니다.', - }, - }, - }, -}; - -export const Disabled: Story = { - args: { - variant: 'bigGoal', - value: '비활성화 예시', - onChange: () => {}, - disabled: true, - }, - render: (args) => , - parameters: { - docs: { - description: { - story: 'disabled=true 시 스타일/동작 예시입니다.', - }, - }, - }, -}; diff --git a/src/common/component/MandalartTextField/MandalartTextField.tsx b/src/common/component/MandalartTextField/MandalartTextField.tsx deleted file mode 100644 index 8a0cce25..00000000 --- a/src/common/component/MandalartTextField/MandalartTextField.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { useReducer, useRef } from 'react'; - -import type { TextFieldProps, TextFieldVariant } from './MandalartTextField.types'; -import * as styles from './MandalartTextField.css'; -import { DEFAULT_PLACEHOLDER, BIG_GOAL_MAX_LENGTH } from './constant/constants'; - -import IcTextdelete from '@/assets/svg/IcTextdelete'; - -type FieldState = 'default' | 'clicked' | 'typing' | 'filled' | 'hover'; - -type State = { - isFocused: boolean; - isHovered: boolean; - isComposing: boolean; -}; - -type Action = - | { type: 'FOCUS' } - | { type: 'BLUR' } - | { type: 'HOVER_ENTER' } - | { type: 'HOVER_LEAVE' } - | { type: 'COMPOSE_START' } - | { type: 'COMPOSE_END' }; - -const reducer = (state: State, action: Action): State => { - switch (action.type) { - case 'FOCUS': - return { ...state, isFocused: true }; - case 'BLUR': - return { ...state, isFocused: false }; - case 'HOVER_ENTER': - return { ...state, isHovered: true }; - case 'HOVER_LEAVE': - return { ...state, isHovered: false }; - case 'COMPOSE_START': - return { ...state, isComposing: true }; - case 'COMPOSE_END': - return { ...state, isComposing: false }; - default: - return state; - } -}; - -const getFieldState = (isFocused: boolean, isHovered: boolean, hasValue: boolean): FieldState => { - if (isFocused) { - return hasValue ? 'typing' : 'clicked'; - } - if (hasValue) { - return 'filled'; - } - if (isHovered) { - return 'hover'; - } - return 'default'; -}; - -const getWrapperClass = (variant: TextFieldVariant, state: FieldState) => { - const variantStyles = { - bigGoal: styles.bigGoalVariants, - subGoal: styles.subGoalVariants, - todo: styles.todoVariants, - }; - return variantStyles[variant][state]; -}; - -const getClearButtonClass = (variant: TextFieldVariant) => - variant === 'bigGoal' ? styles.clearButton : styles.clearButtonSmall; - -const getMaxLength = (variant: TextFieldVariant, maxLength?: number) => { - if (variant === 'bigGoal') { - return maxLength ?? BIG_GOAL_MAX_LENGTH; - } - if (variant === 'subGoal' || variant === 'todo') { - return 30; - } - return undefined; -}; - -const getPlaceholder = (variant: TextFieldVariant, placeholder?: string) => - placeholder ?? DEFAULT_PLACEHOLDER[variant]; - -const TextField = ({ - variant = 'bigGoal', - value, - onChange, - placeholder, - maxLength, - disabled = false, - onKeyDown, - onBlur, - onCompositionStart, - onCompositionEnd, -}: TextFieldProps) => { - const [state, dispatch] = useReducer(reducer, { - isFocused: false, - isHovered: false, - isComposing: false, - }); - const inputRef = useRef(null); - - const hasValue = Boolean(value); - const fieldState = getFieldState(state.isFocused, state.isHovered, hasValue); - - const wrapperClass = getWrapperClass(variant, fieldState); - const clearButtonClass = getClearButtonClass(variant); - const effectiveMaxLength = getMaxLength(variant, maxLength); - const effectivePlaceholder = getPlaceholder(variant, placeholder); - - const handleInputChange = (e: React.ChangeEvent) => { - onChange(e.target.value); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !state.isComposing) { - e.preventDefault(); - e.stopPropagation(); - e.currentTarget.blur(); - } - }; - - const handleClearClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onChange(''); - setTimeout(() => inputRef.current?.focus(), 0); - }; - - const handleContainerClick = () => { - if (!disabled) { - inputRef.current?.focus(); - } - }; - - const handleWrapperKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleContainerClick(); - } - }; - - return ( -
!disabled && dispatch({ type: 'HOVER_ENTER' })} - onMouseLeave={() => dispatch({ type: 'HOVER_LEAVE' })} - onClick={handleContainerClick} - onKeyDown={handleWrapperKeyDown} - role="button" - tabIndex={0} - > - dispatch({ type: 'FOCUS' })} - onBlur={(e) => { - dispatch({ type: 'BLUR' }); - if (onBlur) { - onBlur(e); - } - }} - onKeyDown={onKeyDown ?? handleKeyDown} - onCompositionStart={(e) => { - dispatch({ type: 'COMPOSE_START' }); - if (onCompositionStart) { - onCompositionStart(e); - } - }} - onCompositionEnd={(e) => { - dispatch({ type: 'COMPOSE_END' }); - if (onCompositionEnd) { - onCompositionEnd(e); - } - }} - maxLength={effectiveMaxLength} - /> - {fieldState === 'typing' && ( - - )} -
- ); -}; - -export default TextField; diff --git a/src/common/component/MandalartTextField/MandalartTextField.types.ts b/src/common/component/MandalartTextField/MandalartTextField.types.ts deleted file mode 100644 index 86b4ee1a..00000000 --- a/src/common/component/MandalartTextField/MandalartTextField.types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type TextFieldVariant = 'bigGoal' | 'subGoal' | 'todo'; - -export interface TextFieldProps { - variant?: TextFieldVariant; - value: string; - onChange: (value: string) => void; - placeholder?: string; - maxLength?: number; - disabled?: boolean; - onKeyDown?: React.KeyboardEventHandler; - onBlur?: React.FocusEventHandler; - onCompositionStart?: React.CompositionEventHandler; - onCompositionEnd?: React.CompositionEventHandler; -} diff --git a/src/common/component/MandalartTextField/index.ts b/src/common/component/MandalartTextField/index.ts deleted file mode 100644 index a4f6c7b0..00000000 --- a/src/common/component/MandalartTextField/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './MandalartTextField'; -export * from './MandalartTextField.types'; diff --git a/src/common/component/ModifyTextField/ModifyTextField.css.ts b/src/common/component/ModifyTextField/ModifyTextField.css.ts deleted file mode 100644 index fc2a5b1d..00000000 --- a/src/common/component/ModifyTextField/ModifyTextField.css.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { style, styleVariants } from '@vanilla-extract/css'; - -import { colors, fonts } from '@/style/token'; - -const leftAligned = { textAlign: 'left' as const }; - -const subGoalBase = { - display: 'flex', - width: '62rem', - height: '6.6rem', - padding: '1.6rem 2rem', - alignItems: 'center', - flexShrink: 0, - borderRadius: '8px', - border: '3px solid transparent', - fontSize: '2.2rem', - fontWeight: 600, - lineHeight: '140%', - fontFamily: 'Pretendard', - ...leftAligned, -}; - -const todoBase = { - display: 'flex', - width: '48.5rem', - padding: '1.4rem 2rem', - alignItems: 'center', - borderRadius: '8px', - border: '2px solid transparent', - ...fonts.subtitle03, - ...leftAligned, -}; - -export const subGoalBaseClass = style({ ...subGoalBase }); -export const todoBaseClass = style({ ...todoBase }); - -export const inputBase = style({ - display: 'block', - width: '100%', - height: '100%', - background: 'transparent', - border: 'none', - outline: 'none', - padding: 0, - color: 'inherit', - textAlign: 'inherit', - '::placeholder': { - color: colors.grey6, - }, -}); - -export const subGoalVariants = styleVariants({ - filled: { - ...subGoalBase, - border: `3px solid ${colors.blue04}`, - background: colors.grey2, - color: colors.grey11, - cursor: 'pointer', - }, - empty: { - ...subGoalBase, - border: `3px solid ${colors.blue04}`, - background: colors.grey2, - color: colors.grey6, - cursor: 'pointer', - }, -}); - -export const todoVariants = styleVariants({ - modify_empty: { - ...todoBase, - background: colors.grey4, - color: colors.grey6, - cursor: 'pointer', - }, - modify_hover: { - ...todoBase, - background: colors.grey3, - color: colors.grey6, - cursor: 'pointer', - }, - modify_clicked: { - ...todoBase, - border: `2px solid ${colors.blue06}`, - background: colors.grey3, - color: colors.grey6, - cursor: 'pointer', - }, - modify_typing: { - ...todoBase, - border: `2px solid ${colors.blue06}`, - background: colors.grey3, - color: colors.grey10, - justifyContent: 'space-between', - }, - modify_filled: { - ...todoBase, - background: colors.grey4, - color: colors.grey10, - fontWeight: 600, - cursor: 'pointer', - }, -}); - -export const clearButton = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: '2.4rem', - height: '2.4rem', - background: 'none', - border: 'none', - padding: 0, - cursor: 'pointer', - flexShrink: 0, -}); - -export const inputContainer = style({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flex: '1 0 0', -}); diff --git a/src/common/component/ModifyTextField/ModifyTextField.tsx b/src/common/component/ModifyTextField/ModifyTextField.tsx deleted file mode 100644 index 8a695987..00000000 --- a/src/common/component/ModifyTextField/ModifyTextField.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import type { ModifyTextFieldProps, ModifyTextFieldVariant } from './ModifyTextField.types'; -import * as styles from './ModifyTextField.css'; -import { useModifyTextFieldState, type Action } from './useModifyTextFieldState'; - -import { IcMediumTextdelete } from '@/assets/svg'; - -const DEFAULT_PLACEHOLDER: Record = { - subGoal: '세부 목표를 입력해주세요', - todo: '할 일을 입력해주세요', -}; - -// ====== 타입 정의 ====== -type SubGoalFieldState = 'filled' | 'empty'; -type TodoFieldState = - | 'modify_empty' - | 'modify_hover' - | 'modify_clicked' - | 'modify_typing' - | 'modify_filled'; - -// ====== 상태 결정 함수 분리 ====== -function getSubGoalFieldState(hasValue: boolean): SubGoalFieldState { - return hasValue ? 'filled' : 'empty'; -} - -function getTodoFieldState( - isFocused: boolean, - isHovered: boolean, - hasValue: boolean, -): TodoFieldState { - if (isFocused) { - return hasValue ? 'modify_typing' : 'modify_clicked'; - } - if (hasValue) { - return 'modify_filled'; - } - if (isHovered) { - return 'modify_hover'; - } - return 'modify_empty'; -} - -// ====== 스타일 결정 함수 ====== -function getWrapperClass( - variant: ModifyTextFieldVariant, - fieldState: SubGoalFieldState | TodoFieldState, -) { - if (variant === 'subGoal') { - return styles.subGoalVariants[fieldState as SubGoalFieldState]; - } - return styles.todoVariants[fieldState as TodoFieldState]; -} - -function getPlaceholder(variant: ModifyTextFieldVariant, placeholder?: string) { - return placeholder ?? DEFAULT_PLACEHOLDER[variant]; -} - -function getInputProps({ - value, - onChange, - onFocus, - onBlur, - handleKeyDown, - dispatch, - placeholder, - disabled, -}: { - value: string; - onChange: (value: string) => void; - onFocus?: () => void; - onBlur?: () => void; - handleKeyDown: (e: React.KeyboardEvent) => void; - dispatch: (action: Action) => void; - placeholder?: string; - disabled?: boolean; -}) { - return { - type: 'text', - value, - onChange: (e: React.ChangeEvent) => onChange(e.target.value), - onFocus: () => { - dispatch({ type: 'FOCUS' }); - onFocus?.(); - }, - onBlur: () => { - dispatch({ type: 'BLUR' }); - onBlur?.(); - }, - onKeyDown: handleKeyDown, - onCompositionStart: () => dispatch({ type: 'COMPOSE_START' }), - onCompositionEnd: () => dispatch({ type: 'COMPOSE_END' }), - placeholder, - disabled, - className: styles.inputBase, - }; -} - -function getWrapperProps({ - disabled, - handleWrapperClick, - handleWrapperKeyDown, - dispatch, - isFocused, -}: { - disabled?: boolean; - handleWrapperClick: () => void; - handleWrapperKeyDown: (e: React.KeyboardEvent) => void; - dispatch: (action: Action) => void; - isFocused: boolean; -}) { - return disabled - ? { tabIndex: -1 as const } - : { - role: 'button' as const, - tabIndex: 0 as const, - onClick: isFocused ? undefined : handleWrapperClick, - onKeyDown: handleWrapperKeyDown, - onMouseEnter: () => dispatch({ type: 'HOVER_ENTER' }), - onMouseLeave: () => dispatch({ type: 'HOVER_LEAVE' }), - }; -} - -function ClearButton({ onClick }: { onClick: (e: React.MouseEvent) => void }) { - return ( - - ); -} - -export default function ModifyTextField({ - variant = 'todo', - value, - onChange, - placeholder, - disabled = false, - onBlur, - onFocus, -}: ModifyTextFieldProps) { - const { - state, - dispatch, - inputRef, - handleKeyDown, - handleClearClick, - handleWrapperClick, - handleWrapperKeyDown, - } = useModifyTextFieldState({ onChange }); - - const hasValue = Boolean(value); - const fieldState = - variant === 'subGoal' - ? getSubGoalFieldState(hasValue) - : getTodoFieldState(state.isFocused, state.isHovered, hasValue); - const wrapperClass = getWrapperClass(variant, fieldState); - const effectivePlaceholder = getPlaceholder(variant, placeholder); - const inputProps = getInputProps({ - value, - onChange, - onFocus, - onBlur, - handleKeyDown, - dispatch, - placeholder: effectivePlaceholder, - disabled, - }); - const wrapperProps = getWrapperProps({ - disabled, - handleWrapperClick, - handleWrapperKeyDown, - dispatch, - isFocused: state.isFocused, - }); - const showClearButton = fieldState === 'modify_typing'; - - return ( -
-
- - {showClearButton && } -
-
- ); -} diff --git a/src/common/component/ModifyTextField/ModifyTextField.types.ts b/src/common/component/ModifyTextField/ModifyTextField.types.ts deleted file mode 100644 index 0dc6a5e0..00000000 --- a/src/common/component/ModifyTextField/ModifyTextField.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type ModifyTextFieldVariant = 'subGoal' | 'todo'; - -export interface ModifyTextFieldProps { - variant?: ModifyTextFieldVariant; - value: string; - onChange: (value: string) => void; - placeholder?: string; - disabled?: boolean; - onBlur?: () => void; - onFocus?: () => void; -} diff --git a/src/common/component/ModifyTextField/index 2.ts b/src/common/component/ModifyTextField/index 2.ts deleted file mode 100644 index 15551a09..00000000 --- a/src/common/component/ModifyTextField/index 2.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './ModifyTextField'; -export type { ModifyTextFieldProps, ModifyTextFieldVariant } from './ModifyTextField.types'; diff --git a/src/common/component/ModifyTextField/index.ts b/src/common/component/ModifyTextField/index.ts deleted file mode 100644 index 15551a09..00000000 --- a/src/common/component/ModifyTextField/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './ModifyTextField'; -export type { ModifyTextFieldProps, ModifyTextFieldVariant } from './ModifyTextField.types'; diff --git a/src/common/component/ModifyTextField/useModifyTextFieldState.ts b/src/common/component/ModifyTextField/useModifyTextFieldState.ts deleted file mode 100644 index 05ac8ebd..00000000 --- a/src/common/component/ModifyTextField/useModifyTextFieldState.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { useReducer, useRef, useCallback } from 'react'; - -interface State { - isFocused: boolean; - isHovered: boolean; - isComposing: boolean; -} - -export type Action = - | { type: 'FOCUS' } - | { type: 'BLUR' } - | { type: 'HOVER_ENTER' } - | { type: 'HOVER_LEAVE' } - | { type: 'COMPOSE_START' } - | { type: 'COMPOSE_END' }; - -function reducer(state: State, action: Action): State { - switch (action.type) { - case 'FOCUS': { - return { ...state, isFocused: true }; - } - case 'BLUR': { - return { ...state, isFocused: false }; - } - case 'HOVER_ENTER': { - return { ...state, isHovered: true }; - } - case 'HOVER_LEAVE': { - return { ...state, isHovered: false }; - } - case 'COMPOSE_START': { - return { ...state, isComposing: true }; - } - case 'COMPOSE_END': { - return { ...state, isComposing: false }; - } - default: { - return state; - } - } -} - -export function useModifyTextFieldState({ onChange }: { onChange: (value: string) => void }) { - const [state, dispatch] = useReducer(reducer, { - isFocused: false, - isHovered: false, - isComposing: false, - }); - const inputRef = useRef(null); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !state.isComposing) { - e.preventDefault(); - e.stopPropagation(); - e.currentTarget.blur(); - } - }, - [state.isComposing], - ); - - const handleClearClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onChange(''); - setTimeout(() => inputRef.current?.focus(), 0); - }, - [onChange], - ); - - const handleWrapperClick = useCallback(() => { - if (inputRef.current) { - inputRef.current.focus(); - const len = inputRef.current.value.length ?? 0; - inputRef.current.setSelectionRange(len, len); - } - }, []); - - const handleWrapperKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !state.isFocused) { - e.preventDefault(); - handleWrapperClick(); - } - }, - [handleWrapperClick, state.isFocused], - ); - - return { - state, - dispatch, - inputRef, - handleKeyDown, - handleClearClick, - handleWrapperClick, - handleWrapperKeyDown, - }; -} diff --git a/src/common/component/SignupTextField/RenderInputContent.tsx b/src/common/component/SignupTextField/RenderInputContent.tsx deleted file mode 100644 index 8a79b9a6..00000000 --- a/src/common/component/SignupTextField/RenderInputContent.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; - -import IcSmallTextdelete from '@/assets/svg/IcSmallTextdelete'; -import IcLock from '@/assets/svg/IcLock'; - -interface RenderInputContentProps { - fieldState: string; - inputProps: React.InputHTMLAttributes; - value: string; - isLocked: boolean; - handleClearClick: (e: React.MouseEvent) => void; - styles: typeof import('./SignupTextField.css'); -} - -export function RenderInputContent({ - fieldState, - inputProps, - value, - isLocked, - handleClearClick, - styles, -}: RenderInputContentProps) { - if (fieldState === 'typing' || fieldState === 'typingError') { - return ( - <> - - {value && !isLocked && ( - - )} - - ); - } - if (fieldState === 'error') { - return ; - } - if (fieldState === 'locked') { - return ( -
- - -
- ); - } - return ; -} diff --git a/src/common/component/SignupTextField/SignupTextField.css.ts b/src/common/component/SignupTextField/SignupTextField.css.ts deleted file mode 100644 index de668c6d..00000000 --- a/src/common/component/SignupTextField/SignupTextField.css.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { styleVariants, style } from '@vanilla-extract/css'; - -import { colors } from '@/style/token/color.css'; -import { fonts } from '@/style/token/typography.css'; - -export const baseClass = style({ - display: 'flex', - width: '52.2rem', - height: '5rem', - padding: '1.4rem 2rem', - alignItems: 'center', - flexShrink: 0, - borderRadius: '8px', - border: '2px solid transparent', - ...fonts.body03, -}); - -export const fieldVariants = styleVariants({ - default: { - border: '2px solid transparent', - background: colors.grey4, - color: colors.grey6, - }, - clicked: { - border: `2px solid ${colors.blue06}`, - background: colors.grey3, - color: colors.grey6, - }, - typing: { - border: `2px solid ${colors.blue06}`, - background: colors.grey3, - color: colors.grey10, - }, - filled: { - border: '2px solid transparent', - background: colors.grey4, - color: colors.grey10, - }, - completed: { - border: '2px solid transparent', - background: colors.grey4, - color: colors.grey10, - }, - locked: { - border: '2px solid transparent', - background: colors.grey4, - color: colors.grey5, - }, - error: { - border: `2px solid ${colors.error01}`, - background: colors.grey4, - color: colors.grey10, - }, -}); - -export const inputContent = style({ - display: 'flex', - flex: 1, - alignItems: 'center', - justifyContent: 'space-between', - flexShrink: 0, -}); - -export const clearButton = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: '2rem', - height: '2rem', - background: 'none', - border: 'none', - padding: 0, - cursor: 'pointer', -}); - -export const iconClass = style({ - width: '2rem', - height: '2rem', - flexShrink: 0, -}); - -export const lockIconClass = style({ - width: '2rem', - height: '2rem', - flexShrink: 0, -}); - -export const errorMessageWrapper = style({ - display: 'flex', - width: '52.2rem', - flexDirection: 'column', - alignItems: 'flex-start', - gap: '0.4rem', -}); - -export const inputBase = style({ - display: 'block', - width: '100%', - height: '100%', - background: 'transparent', - border: 'none', - outline: 'none', - padding: 0, - color: 'inherit', -}); - -const makeInputStyle = (font: typeof fonts.body03) => - style({ - ...font, - '::placeholder': { - color: colors.grey6, - ...font, - }, - }); - -export const inputStyle = makeInputStyle(fonts.body03); - -export const errorMessage = style({ - alignSelf: 'stretch', - color: colors.error01, - ...fonts.caption02, -}); diff --git a/src/common/component/SignupTextField/SignupTextField.mock 2.ts b/src/common/component/SignupTextField/SignupTextField.mock 2.ts deleted file mode 100644 index 56ae2187..00000000 --- a/src/common/component/SignupTextField/SignupTextField.mock 2.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const MOCK_SIGNUP_DATA = { - name: '신지수?((#((3ㅓ우렁', - email: 'sjsz0811@hanyang.ac.kr', - birth: '2002-08-11', -}; diff --git a/src/common/component/SignupTextField/SignupTextField.mock.ts b/src/common/component/SignupTextField/SignupTextField.mock.ts deleted file mode 100644 index 56ae2187..00000000 --- a/src/common/component/SignupTextField/SignupTextField.mock.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const MOCK_SIGNUP_DATA = { - name: '신지수?((#((3ㅓ우렁', - email: 'sjsz0811@hanyang.ac.kr', - birth: '2002-08-11', -}; diff --git a/src/common/component/SignupTextField/SignupTextField.tsx b/src/common/component/SignupTextField/SignupTextField.tsx deleted file mode 100644 index 1c05a3f5..00000000 --- a/src/common/component/SignupTextField/SignupTextField.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { SignupTextFieldProps } from './SignupTextField.types'; -import * as styles from './SignupTextField.css'; -import { validateField } from './validation'; -import { useSignupTextFieldState } from './useSignupTextFieldState'; -import { RenderInputContent } from './RenderInputContent'; - -import { formatBirthDate } from '@/common/util/format'; - -function getFieldState( - isFocused: boolean, - hasValue: boolean, - error: boolean, - isLocked: boolean, -): keyof typeof styles.fieldVariants | 'typingError' { - if (isLocked) { - return 'locked'; - } - if (isFocused && hasValue && error) { - return 'typingError'; - } - if (error) { - return 'error'; - } - if (isFocused && hasValue) { - return 'typing'; - } - if (isFocused && !hasValue) { - return 'clicked'; - } - if (!isFocused && hasValue) { - return 'completed'; - } - return 'default'; -} - -export default function SignupTextField({ - id, - type, - value, - onChange, - placeholder, - error: externalError, - onBlur, - onFocus, -}: SignupTextFieldProps) { - const { - state, - dispatch, - inputRef, - handleKeyDown, - handleClearClick, - handleWrapperClick, - handleWrapperKeyDown, - } = useSignupTextFieldState({ onChange }); - - const isLocked = type === 'email'; - const error = - externalError || - (type === 'email' ? undefined : validateField(type as 'name' | 'birth' | 'job', value)); - const hasValue = Boolean(value); - const fieldState = getFieldState(state.isFocused, hasValue, !!error, isLocked); - - const wrapperProps = isLocked - ? { tabIndex: -1 as const } - : { - role: 'button' as const, - tabIndex: 0 as const, - onClick: handleWrapperClick, - onKeyDown: handleWrapperKeyDown, - onMouseEnter: () => dispatch({ type: 'HOVER_ENTER' }), - onMouseLeave: () => dispatch({ type: 'HOVER_LEAVE' }), - }; - - function createInputProps() { - return { - id, - ref: inputRef, - type: 'text' as const, - value, - onChange: (e: React.ChangeEvent) => { - if (!isLocked) { - if (type === 'birth') { - onChange(formatBirthDate(e.target.value)); - } else { - onChange(e.target.value); - } - } - }, - onFocus: () => { - dispatch({ type: 'FOCUS' }); - onFocus?.(); - }, - onBlur: () => { - dispatch({ type: 'BLUR' }); - onBlur?.(); - }, - onKeyDown: handleKeyDown, - onCompositionStart: () => dispatch({ type: 'COMPOSE_START' }), - onCompositionEnd: (e: React.CompositionEvent) => { - dispatch({ type: 'COMPOSE_END' }); - if (type === 'name' || type === 'job') { - const filtered = e.currentTarget.value.replace(/[^a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ\s]/g, ''); - onChange(filtered); - } - }, - placeholder: placeholder ?? (type === 'job' ? '정보를 입력해주세요' : placeholder), - disabled: isLocked, - className: [styles.inputBase, styles.inputStyle].join(' '), - }; - } - - const inputProps = createInputProps(); - - return ( -
-
- -
- {error &&
{error}
} -
- ); -} diff --git a/src/common/component/SignupTextField/SignupTextField.types.ts b/src/common/component/SignupTextField/SignupTextField.types.ts deleted file mode 100644 index 2de39a19..00000000 --- a/src/common/component/SignupTextField/SignupTextField.types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type SignupTextFieldType = 'name' | 'email' | 'birth' | 'job'; - -export interface SignupTextFieldProps { - id?: string; - type: SignupTextFieldType; - value: string; - onChange: (value: string) => void; - placeholder?: string; - error?: string; - disabled?: boolean; - onBlur?: () => void; - onFocus?: () => void; - maxLength?: number; -} diff --git a/src/common/component/SignupTextField/constants.ts b/src/common/component/SignupTextField/constants.ts deleted file mode 100644 index affdb88d..00000000 --- a/src/common/component/SignupTextField/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const ERROR_MESSAGES = { - name: '한글/영문 10자 이하로 입력해주세요', - birth: '정확한 생년월일을 입력해주세요', - job: '한글/영문 15자 이하로 입력해주세요', -} as const; diff --git a/src/common/component/SignupTextField/index 2.ts b/src/common/component/SignupTextField/index 2.ts deleted file mode 100644 index 2d495fe6..00000000 --- a/src/common/component/SignupTextField/index 2.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './SignupTextField'; -export * from './SignupTextField.types'; diff --git a/src/common/component/SignupTextField/index.ts b/src/common/component/SignupTextField/index.ts deleted file mode 100644 index 2d495fe6..00000000 --- a/src/common/component/SignupTextField/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './SignupTextField'; -export * from './SignupTextField.types'; diff --git a/src/common/component/SignupTextField/useSignupTextFieldState.ts b/src/common/component/SignupTextField/useSignupTextFieldState.ts deleted file mode 100644 index 85aeafb2..00000000 --- a/src/common/component/SignupTextField/useSignupTextFieldState.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { useReducer, useRef, useCallback } from 'react'; - -interface State { - isFocused: boolean; - isHovered: boolean; - isComposing: boolean; -} -type Action = - | { type: 'FOCUS' } - | { type: 'BLUR' } - | { type: 'HOVER_ENTER' } - | { type: 'HOVER_LEAVE' } - | { type: 'COMPOSE_START' } - | { type: 'COMPOSE_END' }; - -function reducer(state: State, action: Action): State { - switch (action.type) { - case 'FOCUS': { - return { ...state, isFocused: true }; - } - case 'BLUR': { - return { ...state, isFocused: false }; - } - case 'HOVER_ENTER': { - return { ...state, isHovered: true }; - } - case 'HOVER_LEAVE': { - return { ...state, isHovered: false }; - } - case 'COMPOSE_START': { - return { ...state, isComposing: true }; - } - case 'COMPOSE_END': { - return { ...state, isComposing: false }; - } - default: { - return state; - } - } -} - -export function useSignupTextFieldState({ onChange }: { onChange: (value: string) => void }) { - const [state, dispatch] = useReducer(reducer, { - isFocused: false, - isHovered: false, - isComposing: false, - }); - const inputRef = useRef(null); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !state.isComposing) { - e.preventDefault(); - e.stopPropagation(); - e.currentTarget.blur(); - } - }, - [state.isComposing], - ); - - const handleClearClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onChange(''); - setTimeout(() => inputRef.current?.focus(), 0); - }, - [onChange], - ); - - const handleWrapperClick = useCallback(() => { - if (inputRef.current) { - inputRef.current.focus(); - const len = inputRef.current.value.length ?? 0; - inputRef.current.setSelectionRange(len, len); - } - }, []); - - const handleWrapperKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleWrapperClick(); - } - }, - [handleWrapperClick], - ); - - return { - state, - dispatch, - inputRef, - handleKeyDown, - handleClearClick, - handleWrapperClick, - handleWrapperKeyDown, - }; -} diff --git a/src/common/component/SignupTextField/validation.ts b/src/common/component/SignupTextField/validation.ts deleted file mode 100644 index 745761fc..00000000 --- a/src/common/component/SignupTextField/validation.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ERROR_MESSAGES } from './constants'; - -export const NAME_MAX_LENGTH = 10; -export const JOB_MAX_LENGTH = 15; -export const BIRTH_REGEX = /^\d{4}\.\d{2}\.\d{2}$/; -export const NAME_REGEX = /^[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ\s]*$/; -export const JOB_REGEX = /^[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ\s]*$/; - -export function validateField(type: 'name' | 'birth' | 'job', value: string): string | undefined { - if (type === 'name') { - if (!NAME_REGEX.test(value) || value.length > NAME_MAX_LENGTH) { - return ERROR_MESSAGES.name; - } - return undefined; - } - if (type === 'birth') { - if (!value) { - return undefined; - } - if (!BIRTH_REGEX.test(value)) { - return ERROR_MESSAGES.birth; - } - const [year, month, day] = value.split('.'); - const monthNum = parseInt(month, 10); - if (monthNum < 1 || monthNum > 12) { - return ERROR_MESSAGES.birth; - } - const dayNum = parseInt(day, 10); - if (dayNum < 1 || dayNum > 31) { - return ERROR_MESSAGES.birth; - } - const now = new Date(); - const utc = now.getTime() + now.getTimezoneOffset() * 60000; - const koreaNow = new Date(utc + 9 * 60 * 60 * 1000); - const todayStr = koreaNow.toISOString().slice(0, 10); - const [todayYear, todayMonth, todayDay] = todayStr.split('-'); - const today = new Date(`${todayYear}-${todayMonth}-${todayDay}`); - const inputDate = new Date(`${year}-${month}-${day}`); - if (inputDate.getTime() > today.getTime()) { - return ERROR_MESSAGES.birth; - } - return undefined; - } - if (type === 'job') { - if (!JOB_REGEX.test(value) || value.length > JOB_MAX_LENGTH) { - return ERROR_MESSAGES.job; - } - return undefined; - } - return undefined; -} diff --git a/src/common/component/TextField/BaseTextField.tsx b/src/common/component/TextField/BaseTextField.tsx new file mode 100644 index 00000000..b5871bcc --- /dev/null +++ b/src/common/component/TextField/BaseTextField.tsx @@ -0,0 +1,162 @@ +import type { ReactNode } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +type CommitReason = 'enter' | 'blur'; + +export interface BaseTextFieldProps { + id?: string; + value: string; + onChange: (value: string) => void; + onCommit?: (value: string, reason: CommitReason) => void; + onClear?: () => void; + maxLength?: number; + disabled?: boolean; + invalid?: boolean; + children: (args: { + inputProps: React.ComponentPropsWithRef<'input'>; + hasValue: boolean; + isFocused: boolean; + isComposing: boolean; + length: number; + remainingLength?: number; + clear: () => void; + commit: (reason: CommitReason) => void; + }) => ReactNode; +} + +const BaseTextField = ({ + id, + value, + onChange, + onCommit, + onClear, + maxLength, + disabled, + invalid, + children, +}: BaseTextFieldProps) => { + const [isComposing, setIsComposing] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const lastCommittedRef = useRef(null); + const skipBlurOnceRef = useRef(false); + + const isDisabled = !!disabled; + + const hasValue = Boolean(value); + const length = value.length; + const remainingLength = + typeof maxLength === 'number' ? Math.max(0, maxLength - length) : undefined; + + const commit = useCallback( + (reason: CommitReason) => { + if (lastCommittedRef.current === value) { + return; + } + onCommit?.(value, reason); + lastCommittedRef.current = value; + }, + [onCommit, value], + ); + + const clear = useCallback(() => { + onChange(''); + if (onClear) { + onClear(); + } + }, [onChange, onClear]); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + if (isDisabled) { + return; + } + const raw = e.target.value; + onChange(raw); + }, + [isDisabled, onChange], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const nativeEvent = e.nativeEvent as unknown as { isComposing?: boolean }; + const nativeComposing = Boolean(nativeEvent?.isComposing); + if (e.key === 'Enter' && !isComposing && !nativeComposing) { + e.preventDefault(); + e.stopPropagation(); + skipBlurOnceRef.current = true; + commit('enter'); + e.currentTarget.blur?.(); + return; + } + }, + [isComposing, commit], + ); + + const handleFocus = useCallback(() => { + setIsFocused(true); + }, []); + + const handleBlur = useCallback(() => { + setIsFocused(false); + if (isComposing || skipBlurOnceRef.current) { + skipBlurOnceRef.current = false; + return; + } + commit('blur'); + }, [isComposing, commit]); + + const handleCompositionStart = useCallback(() => { + setIsComposing(true); + }, []); + + const handleCompositionEnd = useCallback(() => { + setIsComposing(false); + }, []); + + const inputProps = useMemo(() => { + const base: React.ComponentPropsWithRef<'input'> = { + id, + value, + onChange: handleChange, + onKeyDown: handleKeyDown, + onFocus: handleFocus, + onBlur: handleBlur, + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + maxLength: typeof maxLength === 'number' ? maxLength : undefined, + readOnly: isDisabled, + 'aria-readonly': isDisabled, + 'aria-invalid': invalid || undefined, + }; + return base; + }, [ + id, + value, + isDisabled, + invalid, + handleChange, + handleKeyDown, + handleFocus, + handleBlur, + handleCompositionStart, + handleCompositionEnd, + maxLength, + ]); + + return ( + <> + {children({ + inputProps, + hasValue, + isFocused, + isComposing, + length, + remainingLength, + clear, + commit, + })} + + ); +}; + +export default BaseTextField; diff --git a/src/common/component/TextField/mandalart/MandalartTextField.css.ts b/src/common/component/TextField/mandalart/MandalartTextField.css.ts new file mode 100644 index 00000000..737f2eb7 --- /dev/null +++ b/src/common/component/TextField/mandalart/MandalartTextField.css.ts @@ -0,0 +1,142 @@ +import { style, styleVariants } from '@vanilla-extract/css'; + +import { colors, fonts } from '@/style/token'; + +const makeBox = (width: string, height: string, padding: string, borderRadius: string) => ({ + display: 'flex', + alignItems: 'center', + flexShrink: 0, + width, + height, + padding, + borderRadius, +}); + +const bigGoalBox = makeBox('57.1rem', '8rem', '2rem 3rem', '12px'); +const subGoalBox = makeBox('57.1rem', '5.6rem', '1.4rem 2rem', '8px'); +const todoBox = makeBox('43.6rem', '5.6rem', '1.4rem 2rem', '8px'); + +export const inputBase = style({ + display: 'block', + width: '100%', + height: '100%', + background: 'transparent', + border: 'none', + outline: 'none', + padding: 0, + color: 'inherit', + textAlign: 'left', +}); + +const makeInputFont = (font: Record) => + style({ + ...font, + }); + +export const inputFontVariants = styleVariants({ + bigGoal: [inputBase, makeInputFont(fonts.title01)], + subGoal: [inputBase, makeInputFont(fonts.subtitle03)], + todo: [inputBase, makeInputFont(fonts.subtitle03)], +}); + +const createInputStateVariants = () => { + return styleVariants({ + default: { + color: colors.grey6, + }, + clicked: { + color: colors.grey6, + }, + typing: { + color: colors.grey10, + }, + filled: { + color: colors.grey10, + }, + hover: { + color: colors.grey6, + }, + }); +}; + +const commonInputStateVariants = createInputStateVariants(); + +export const inputStateVariants = { + bigGoal: commonInputStateVariants, + subGoal: commonInputStateVariants, + todo: commonInputStateVariants, +}; + +type VariantOptions = { + borderWidth: string; + activeBorderColor: string; +}; + +const createVariants = (box: object, opts: VariantOptions) => { + const { borderWidth, activeBorderColor } = opts; + return { + default: { + ...box, + border: `${borderWidth} solid transparent`, + background: colors.grey4, + }, + clicked: { + ...box, + border: `${borderWidth} solid ${activeBorderColor}`, + background: colors.grey3, + }, + typing: { + ...box, + border: `${borderWidth} solid ${activeBorderColor}`, + background: colors.grey3, + justifyContent: 'space-between', + }, + filled: { + ...box, + border: `${borderWidth} solid transparent`, + background: colors.grey4, + }, + hover: { + ...box, + border: `${borderWidth} solid transparent`, + background: colors.grey3, + }, + }; +}; + +export const bigGoalVariants = styleVariants( + createVariants(bigGoalBox, { + borderWidth: '3px', + activeBorderColor: colors.grey5, + }), +); + +export const subGoalVariants = styleVariants( + createVariants(subGoalBox, { + borderWidth: '2px', + activeBorderColor: colors.blue06, + }), +); + +export const todoVariants = styleVariants( + createVariants(todoBox, { + borderWidth: '2px', + activeBorderColor: colors.blue06, + }), +); + +const makeIconButton = (size: string) => + style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: size, + height: size, + background: 'none', + border: 'none', + padding: 0, + cursor: 'pointer', + }); + +export const clearButton = makeIconButton('3.2rem'); +export const clearButtonSmall = makeIconButton('2.4rem'); diff --git a/src/common/component/TextField/mandalart/MandalartTextField.stories.tsx b/src/common/component/TextField/mandalart/MandalartTextField.stories.tsx new file mode 100644 index 00000000..b08a8127 --- /dev/null +++ b/src/common/component/TextField/mandalart/MandalartTextField.stories.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { MandalartTextField } from './'; + +const meta = { + title: 'Components/TextField/MandalartTextField', + component: MandalartTextField, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const BigGoal: Story = { + args: { variant: 'bigGoal', value: '', onChange: () => {} }, + render: () => { + const [value, setValue] = useState(''); + return ; + }, +}; + +export const SubGoal: Story = { + args: { variant: 'subGoal', value: '', onChange: () => {} }, + render: () => { + const [value, setValue] = useState(''); + return ; + }, +}; + +export const Todo: Story = { + args: { variant: 'todo', value: '', onChange: () => {} }, + render: () => { + const [value, setValue] = useState(''); + return ; + }, +}; diff --git a/src/common/component/TextField/mandalart/MandalartTextField.tsx b/src/common/component/TextField/mandalart/MandalartTextField.tsx new file mode 100644 index 00000000..405541fc --- /dev/null +++ b/src/common/component/TextField/mandalart/MandalartTextField.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; + +import IcTextdelete from '@/assets/svg/IcTextdelete'; + +import { type MandalartVariant, BIG_GOAL_MAX_LENGTH, DEFAULT_PLACEHOLDER } from './constants'; +import * as s from './MandalartTextField.css'; + +import BaseTextField from '../BaseTextField'; + +type FieldState = 'default' | 'clicked' | 'typing' | 'filled' | 'hover'; + +const pickMaxLength = (variant: MandalartVariant, maxLength?: number) => + variant === 'bigGoal' ? (maxLength ?? BIG_GOAL_MAX_LENGTH) : (maxLength ?? undefined); + +const pickPlaceholder = (variant: MandalartVariant, placeholder?: string) => + placeholder ?? DEFAULT_PLACEHOLDER[variant]; + +const computeFieldState = (args: { + hasValue: boolean; + isFocused: boolean; + isHovered: boolean; +}): FieldState => { + const { hasValue, isFocused, isHovered } = args; + if (isFocused) { + return hasValue ? 'typing' : 'clicked'; + } + if (hasValue) { + return 'filled'; + } + return isHovered ? 'hover' : 'default'; +}; + +export interface MandalartTextFieldProps { + variant?: MandalartVariant; + value: string; + onChange: (value: string) => void; + onCommit?: (value: string, reason: 'enter' | 'blur') => void; + placeholder?: string; + maxLength?: number; + disabled?: boolean; +} + +const MandalartTextField = ({ + variant = 'bigGoal', + value, + onChange, + onCommit, + placeholder, + maxLength, + disabled, +}: MandalartTextFieldProps) => { + const [isHovered, setIsHovered] = useState(false); + + const effectiveMaxLength = pickMaxLength(variant, maxLength); + const effectivePlaceholder = pickPlaceholder(variant, placeholder); + + const wrapperVariants = + variant === 'bigGoal' + ? s.bigGoalVariants + : variant === 'subGoal' + ? s.subGoalVariants + : s.todoVariants; + + const clearButtonClass = variant === 'bigGoal' ? s.clearButton : s.clearButtonSmall; + + return ( + + {({ inputProps, hasValue, isFocused, clear }) => { + const state = computeFieldState({ hasValue, isFocused, isHovered }); + const stateStylesByVariant = s.inputStateVariants[variant] ?? s.inputStateVariants.bigGoal; + const inputClass = `${s.inputFontVariants[variant]} ${stateStylesByVariant[state]}`; + const wrapperClass = wrapperVariants[state]; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {state === 'typing' && ( + + )} +
+ ); + }} +
+ ); +}; + +export default MandalartTextField; diff --git a/src/common/component/MandalartTextField/constant/constants.ts b/src/common/component/TextField/mandalart/constants.ts similarity index 78% rename from src/common/component/MandalartTextField/constant/constants.ts rename to src/common/component/TextField/mandalart/constants.ts index a0e9984e..a728dc1a 100644 --- a/src/common/component/MandalartTextField/constant/constants.ts +++ b/src/common/component/TextField/mandalart/constants.ts @@ -1,3 +1,5 @@ +export type MandalartVariant = 'bigGoal' | 'subGoal' | 'todo'; + export const DEFAULT_PLACEHOLDER = { bigGoal: '이루고 싶은 목표를 작성하세요', subGoal: '세부 목표를 입력해주세요', diff --git a/src/common/component/TextField/mandalart/index.ts b/src/common/component/TextField/mandalart/index.ts new file mode 100644 index 00000000..98ecf83f --- /dev/null +++ b/src/common/component/TextField/mandalart/index.ts @@ -0,0 +1,3 @@ +export { default as MandalartTextField } from './MandalartTextField'; +export type { MandalartTextFieldProps } from './MandalartTextField'; +export type { MandalartVariant } from './constants'; diff --git a/src/common/component/TextField/modify/ModifyTextField.css.ts b/src/common/component/TextField/modify/ModifyTextField.css.ts new file mode 100644 index 00000000..0e91917c --- /dev/null +++ b/src/common/component/TextField/modify/ModifyTextField.css.ts @@ -0,0 +1,99 @@ +import { style, styleVariants } from '@vanilla-extract/css'; + +import { colors, fonts } from '@/style/token'; + +export const inputBase = style({ + display: 'block', + width: '100%', + height: '100%', + background: 'transparent', + border: 'none', + outline: 'none', + padding: 0, + color: 'inherit', + textAlign: 'left', +}); + +const makeBox = (width: string, height: string, padding: string) => ({ + display: 'flex', + alignItems: 'center', + flexShrink: 0, + width, + height, + padding, + borderRadius: '8px', +}); + +const subGoalBoxBase = makeBox('62rem', '6.6rem', '1.6rem 2rem'); +const todoBoxBase = makeBox('48.5rem', '5.6rem', '1.4rem 2rem'); + +const subGoalBase = { + ...subGoalBoxBase, + border: `3px solid ${colors.gradient04}`, + background: colors.grey2, +}; + +export const subGoalBox = style({ + ...subGoalBase, + ':hover': { + cursor: 'pointer', + }, +}); + +const createVariants = (box: object, opts: { borderWidth: string; activeBorderColor: string }) => { + const { borderWidth, activeBorderColor } = opts; + const baseDefault = { + ...box, + border: `${borderWidth} solid transparent`, + background: colors.grey4, + }; + const baseActive = { + ...box, + border: `${borderWidth} solid ${activeBorderColor}`, + background: colors.grey3, + }; + const baseHoverOrFilled = { + ...box, + border: `${borderWidth} solid transparent`, + background: colors.grey4, + }; + return { + default: baseDefault, + clicked: baseActive, + typing: { ...baseActive, justifyContent: 'space-between' }, + filled: baseHoverOrFilled, + hover: { ...baseHoverOrFilled, background: colors.grey3 }, + } as const; +}; + +export const todoBox = styleVariants( + createVariants(todoBoxBase, { borderWidth: '2px', activeBorderColor: colors.blue06 }), +); + +export const subGoalInput = styleVariants({ + default: [inputBase, fonts.subtitle03, { color: colors.grey6 }], + hover: [inputBase, fonts.subtitle03, { color: colors.grey11 }], + clicked: [inputBase, fonts.subtitle03, { color: colors.grey11 }], + typing: [inputBase, fonts.subtitle03, { color: colors.grey11 }], + filled: [inputBase, fonts.subtitle03, { color: colors.grey11 }], +}); + +export const todoInput = styleVariants({ + default: [inputBase, fonts.subtitle03, { color: colors.grey6 }], + hover: [inputBase, fonts.subtitle03, { color: colors.grey6 }], + clicked: [inputBase, fonts.subtitle03, { color: colors.grey6 }], + typing: [inputBase, fonts.subtitle03, { color: colors.grey10 }], + filled: [inputBase, fonts.subtitle02, { color: colors.grey10 }], +}); + +export const clearButton = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '2.4rem', + height: '2.4rem', + background: 'none', + border: 'none', + padding: 0, + cursor: 'pointer', +}); diff --git a/src/common/component/TextField/modify/ModifyTextField.stories.tsx b/src/common/component/TextField/modify/ModifyTextField.stories.tsx new file mode 100644 index 00000000..7bfb9f0a --- /dev/null +++ b/src/common/component/TextField/modify/ModifyTextField.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { ModifyTextField } from './'; + +const meta = { + title: 'Components/TextField/ModifyTextField', + component: ModifyTextField, + parameters: { layout: 'centered' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SubGoalFilled: Story = { + args: { variant: 'subGoal', value: '', onChange: () => {} }, + render: () => { + const [value, setValue] = useState('나의 하위 목표'); + return ; + }, +}; + +export const SubGoalEmpty: Story = { + args: { variant: 'subGoal', value: '', onChange: () => {} }, + render: () => { + const [value, setValue] = useState(''); + return ; + }, +}; + +export const TodoEmpty: Story = { + args: { variant: 'todo', value: '', onChange: () => {} }, + render: () => { + const [value, setValue] = useState(''); + return ; + }, +}; + +export const TodoFilled: Story = { + args: { variant: 'todo', value: '', onChange: () => {} }, + render: () => { + const [value, setValue] = useState('완료된 할 일'); + return ; + }, +}; diff --git a/src/common/component/TextField/modify/ModifyTextField.tsx b/src/common/component/TextField/modify/ModifyTextField.tsx new file mode 100644 index 00000000..7f7c26f0 --- /dev/null +++ b/src/common/component/TextField/modify/ModifyTextField.tsx @@ -0,0 +1,85 @@ +import { useCallback, useState } from 'react'; + +import IcTextdelete from '@/assets/svg/IcTextdelete'; + +import BaseTextField from '../BaseTextField'; +import { type ModifyVariant, DEFAULT_PLACEHOLDER } from './constants.ts'; +import * as s from './ModifyTextField.css.ts'; + +type FieldState = 'default' | 'hover' | 'clicked' | 'typing' | 'filled'; + +const pickPlaceholder = (variant: ModifyVariant, placeholder?: string) => + placeholder ?? DEFAULT_PLACEHOLDER[variant]; + +const computeFieldState = (args: { + hasValue: boolean; + isFocused: boolean; + isHovered: boolean; +}): FieldState => { + const { hasValue, isFocused, isHovered } = args; + if (isFocused) { + return hasValue ? 'typing' : 'clicked'; + } + if (hasValue) { + return 'filled'; + } + return isHovered ? 'hover' : 'default'; +}; + +export interface ModifyTextFieldProps { + variant: ModifyVariant; + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; +} + +const ModifyTextField = ({ + variant, + value, + onChange, + placeholder, + disabled, +}: ModifyTextFieldProps) => { + const [isHovered, setIsHovered] = useState(false); + + const effectivePlaceholder = pickPlaceholder(variant, placeholder); + + const handleMouseEnter = useCallback(() => setIsHovered(true), []); + const handleMouseLeave = useCallback(() => setIsHovered(false), []); + + const isTodo = variant === 'todo'; + + return ( + + {({ inputProps, hasValue, isFocused, clear }) => { + const state = computeFieldState({ hasValue, isFocused, isHovered }); + const wrapperClass = variant === 'subGoal' ? s.subGoalBox : s.todoBox[state]; + const inputClass = variant === 'subGoal' ? s.subGoalInput[state] : s.todoInput[state]; + + return ( +
+ + {isTodo && state === 'typing' && ( + + )} +
+ ); + }} +
+ ); +}; + +export default ModifyTextField; diff --git a/src/common/component/TextField/modify/constants.ts b/src/common/component/TextField/modify/constants.ts new file mode 100644 index 00000000..51cfbcf0 --- /dev/null +++ b/src/common/component/TextField/modify/constants.ts @@ -0,0 +1,6 @@ +export type ModifyVariant = 'subGoal' | 'todo'; + +export const DEFAULT_PLACEHOLDER = { + subGoal: '수정할 목표를 입력해주세요.', + todo: '목표를 입력해주세요.', +} as const; diff --git a/src/common/component/TextField/modify/index.ts b/src/common/component/TextField/modify/index.ts new file mode 100644 index 00000000..f2fbf9c1 --- /dev/null +++ b/src/common/component/TextField/modify/index.ts @@ -0,0 +1,3 @@ +export { default as ModifyTextField } from './ModifyTextField'; +export type { ModifyTextFieldProps } from './ModifyTextField'; +export type { ModifyVariant } from './constants'; diff --git a/src/common/component/TextField/signup/SignupTextField.css.ts b/src/common/component/TextField/signup/SignupTextField.css.ts new file mode 100644 index 00000000..6c14ec07 --- /dev/null +++ b/src/common/component/TextField/signup/SignupTextField.css.ts @@ -0,0 +1,110 @@ +import { style, styleVariants } from '@vanilla-extract/css'; + +import { colors, fonts } from '@/style/token'; + +const box = { + display: 'flex', + width: '52.2rem', + height: '5rem', + padding: '1.4rem 2rem', + alignItems: 'center', + flexShrink: 0, + borderRadius: '8px', +}; + +export const inputBase = style({ + display: 'block', + width: '100%', + height: '100%', + background: 'transparent', + border: 'none', + outline: 'none', + padding: 0, + color: 'inherit', + textAlign: 'left', +}); + +export const inputFont = style([inputBase, fonts.body03]); + +const createInputStateVariants = () => + styleVariants({ + default: { color: colors.grey6 }, + clicked: { color: colors.grey6 }, + typing: { color: colors.grey10 }, + filled: { color: colors.grey10 }, + hover: { color: colors.grey6 }, + disabled: { color: colors.grey5 }, + }); + +export const inputState = createInputStateVariants(); + +const createBoxVariants = (activeBorderColor: string) => { + return styleVariants({ + default: { + ...box, + border: `2px solid transparent`, + background: colors.grey4, + }, + clicked: { + ...box, + border: `2px solid ${activeBorderColor}`, + background: colors.grey3, + }, + typing: { + ...box, + border: `2px solid ${activeBorderColor}`, + background: colors.grey3, + justifyContent: 'space-between', + }, + filled: { + ...box, + border: `2px solid transparent`, + background: colors.grey4, + }, + hover: { + ...box, + border: `2px solid transparent`, + background: colors.grey3, + }, + disabled: { + ...box, + border: `2px solid transparent`, + background: colors.grey4, + justifyContent: 'space-between', + pointerEvents: 'none', + }, + }); +}; + +export const fieldBox = createBoxVariants(colors.blue06); + +export const clearButton = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '2rem', + height: '2rem', + background: 'none', + border: 'none', + padding: 0, + cursor: 'pointer', +}); + +export const lockIcon = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '2rem', + height: '2rem', + color: colors.grey5, +}); + +export const errorContainer = style({ + display: 'flex', + width: '52.2rem', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '0.4rem', +}); + +export const errorText = style([fonts.caption02, { color: colors.error01, alignSelf: 'stretch' }]); diff --git a/src/common/component/TextField/signup/SignupTextField.stories.tsx b/src/common/component/TextField/signup/SignupTextField.stories.tsx new file mode 100644 index 00000000..7e55fe0b --- /dev/null +++ b/src/common/component/TextField/signup/SignupTextField.stories.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { SignupTextField } from './'; + +const meta = { + title: 'Components/TextField/SignupTextField', + component: SignupTextField, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Name: Story = { + args: { variant: 'name', value: '', onChange: () => {} }, + render: () => { + const [value, setValue] = useState(''); + return ; + }, +}; + +export const EmailDisabled: Story = { + args: { variant: 'email', value: '', onChange: () => {} }, + render: () => { + const [value] = useState('user@example.com'); + return {}} disabled />; + }, +}; + +export const Birth: Story = { + args: { variant: 'birth', value: '', onChange: () => {} }, + render: () => { + const [value, setValue] = useState(''); + return ; + }, +}; + +export const Job: Story = { + args: { variant: 'job', value: '', onChange: () => {} }, + render: () => { + const [value, setValue] = useState(''); + return ; + }, +}; diff --git a/src/common/component/TextField/signup/SignupTextField.tsx b/src/common/component/TextField/signup/SignupTextField.tsx new file mode 100644 index 00000000..5c274a0f --- /dev/null +++ b/src/common/component/TextField/signup/SignupTextField.tsx @@ -0,0 +1,130 @@ +import { useCallback, useState } from 'react'; + +import IcLock from '@/assets/svg/IcLock'; +import IcMediumTextdelete from '@/assets/svg/IcMediumTextdelete'; + +import BaseTextField from '../BaseTextField'; +import { DEFAULT_PLACEHOLDER, type SignupVariant } from './constants'; +import * as s from './SignupTextField.css'; +import { formatBirthDate } from './format'; +import { validateSignupField } from './validation'; + +type FieldState = 'default' | 'clicked' | 'typing' | 'filled' | 'hover' | 'disabled'; + +const pickPlaceholder = (variant: SignupVariant, placeholder?: string) => + placeholder ?? DEFAULT_PLACEHOLDER[variant]; + +const computeFieldState = (args: { + hasValue: boolean; + isFocused: boolean; + isHovered: boolean; + disabled: boolean; +}): FieldState => { + const { hasValue, isFocused, isHovered, disabled } = args; + if (disabled) { + return 'disabled'; + } + if (isFocused) { + return hasValue ? 'typing' : 'clicked'; + } + if (hasValue) { + return 'filled'; + } + return isHovered ? 'hover' : 'default'; +}; + +export interface SignupTextFieldProps { + id?: string; + variant: SignupVariant; + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; +} + +const SignupTextField = ({ + id, + variant, + value, + onChange, + placeholder, + disabled, +}: SignupTextFieldProps) => { + const [isHovered, setIsHovered] = useState(false); + + const effectivePlaceholder = pickPlaceholder(variant, placeholder); + + const handleMouseEnter = useCallback(() => setIsHovered(true), []); + const handleMouseLeave = useCallback(() => setIsHovered(false), []); + + const handleChange = useCallback( + (nextValue: string) => { + if (variant === 'birth') { + onChange(formatBirthDate(nextValue)); + return; + } + onChange(nextValue); + }, + [onChange, variant], + ); + + const signupType = variant === 'email' ? undefined : (variant as 'name' | 'birth' | 'job'); + let computedInvalid: boolean | undefined; + let computedError: string | undefined; + if (signupType) { + const msg = validateSignupField(signupType, value); + if (msg) { + computedInvalid = true; + computedError = msg; + } + } + + return ( + + {({ inputProps, hasValue, isFocused, clear }) => { + const state = computeFieldState({ hasValue, isFocused, isHovered, disabled: !!disabled }); + const wrapperClass = s.fieldBox[state]; + const inputClass = s.inputState[state]; + + return ( +
+
+ + {state === 'typing' && variant !== 'email' && ( + + )} + {state === 'disabled' && ( + + + + )} +
+ {computedInvalid && ( +

+ {computedError || '유효하지 않은 입력입니다.'} +

+ )} +
+ ); + }} +
+ ); +}; + +export default SignupTextField; diff --git a/src/common/component/TextField/signup/constants.ts b/src/common/component/TextField/signup/constants.ts new file mode 100644 index 00000000..c2132e05 --- /dev/null +++ b/src/common/component/TextField/signup/constants.ts @@ -0,0 +1,14 @@ +export type SignupVariant = 'name' | 'email' | 'birth' | 'job'; + +export const DEFAULT_PLACEHOLDER = { + name: '이름을 입력해주세요', + email: '', + birth: '생년월일 8자를 입력해주세요', + job: '직업을 입력해주세요', +} as const; + +export const ERROR_MESSAGES = { + name: '한글/영문 10자 이하로 입력해주세요', + birth: '정확한 생년월일 8자를 입력해주세요', + job: '한글/영문 15자 이하로 입력해주세요', +} as const; diff --git a/src/common/component/TextField/signup/format.ts b/src/common/component/TextField/signup/format.ts new file mode 100644 index 00000000..671b1c6e --- /dev/null +++ b/src/common/component/TextField/signup/format.ts @@ -0,0 +1,10 @@ +export function formatBirthDate(value: string): string { + const digits = value.replace(/[^0-9]/g, '').slice(0, 8); + if (digits.length < 5) { + return digits; + } + if (digits.length < 7) { + return `${digits.slice(0, 4)}-${digits.slice(4)}`; + } + return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`; +} diff --git a/src/common/component/TextField/signup/index.ts b/src/common/component/TextField/signup/index.ts new file mode 100644 index 00000000..05ca3bbb --- /dev/null +++ b/src/common/component/TextField/signup/index.ts @@ -0,0 +1,3 @@ +export { default as SignupTextField } from './SignupTextField'; +export type { SignupTextFieldProps } from './SignupTextField'; +export type { SignupVariant } from './constants'; diff --git a/src/common/component/TextField/signup/validation.ts b/src/common/component/TextField/signup/validation.ts new file mode 100644 index 00000000..011ba92a --- /dev/null +++ b/src/common/component/TextField/signup/validation.ts @@ -0,0 +1,86 @@ +import { ERROR_MESSAGES } from './constants'; + +export const NAME_REGEX = /^[A-Za-z가-힣ㄱ-ㅎㅏ-ㅣ\s]{1,10}$/; +export const JOB_REGEX = /^[A-Za-z가-힣ㄱ-ㅎㅏ-ㅣ\s]{1,15}$/; +export const BIRTH_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +export function validateName(value: string): string | undefined { + const v = (value ?? '').trim(); + if (!v) { + return undefined; + } + if (!NAME_REGEX.test(v)) { + return ERROR_MESSAGES.name; + } + return undefined; +} + +export function validateBirth(value: string): string | undefined { + const v = (value ?? '').trim(); + if (!v) { + return undefined; + } + if (!BIRTH_REGEX.test(v)) { + return ERROR_MESSAGES.birth; + } + + const [yy, mm, dd] = v.split('-'); + const yearNum = parseInt(yy, 10); + const monthNum = parseInt(mm, 10); + const dayNum = parseInt(dd, 10); + if (monthNum < 1 || monthNum > 12) { + return ERROR_MESSAGES.birth; + } + if (dayNum < 1 || dayNum > 31) { + return ERROR_MESSAGES.birth; + } + + const exact = new Date(yearNum, monthNum - 1, dayNum); + if ( + exact.getFullYear() !== yearNum || + exact.getMonth() !== monthNum - 1 || + exact.getDate() !== dayNum + ) { + return ERROR_MESSAGES.birth; + } + + const now = new Date(); + const utc = now.getTime() + now.getTimezoneOffset() * 60000; + const koreaNow = new Date(utc + 9 * 60 * 60 * 1000); + const todayIso = koreaNow.toISOString().slice(0, 10); + const [ty, tm, td] = todayIso.split('-'); + const today = new Date(`${ty}-${tm}-${td}`); + const inputDate = new Date(`${yearNum}-${mm}-${dd}`); + if (inputDate.getTime() > today.getTime()) { + return ERROR_MESSAGES.birth; + } + + return undefined; +} + +export function validateJob(value: string): string | undefined { + const v = (value ?? '').trim(); + if (!v) { + return undefined; + } + if (!JOB_REGEX.test(v)) { + return ERROR_MESSAGES.job; + } + return undefined; +} + +export function validateSignupField( + type: 'name' | 'birth' | 'job', + value: string, +): string | undefined { + if (type === 'name') { + return validateName(value); + } + if (type === 'birth') { + return validateBirth(value); + } + if (type === 'job') { + return validateJob(value); + } + return undefined; +} diff --git a/src/common/util/format.ts b/src/common/util/format.ts index 9a56edce..2ee6d00e 100644 --- a/src/common/util/format.ts +++ b/src/common/util/format.ts @@ -1,14 +1,3 @@ -export function formatBirthDate(value: string): string { - const digits = value.replace(/[^0-9]/g, '').slice(0, 8); - if (digits.length < 5) { - return digits; - } - if (digits.length < 7) { - return `${digits.slice(0, 4)}.${digits.slice(4)}`; - } - return `${digits.slice(0, 4)}.${digits.slice(4, 6)}.${digits.slice(6)}`; -} - export function formatDateDot(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); diff --git a/src/page/edit/component/HoverContent/HoverContent.tsx b/src/page/edit/component/HoverContent/HoverContent.tsx index 46259c54..93541068 100644 --- a/src/page/edit/component/HoverContent/HoverContent.tsx +++ b/src/page/edit/component/HoverContent/HoverContent.tsx @@ -3,7 +3,8 @@ import { useState, useEffect } from 'react'; import * as styles from './HoverContent.css'; import Mandalart from '@/common/component/Mandalart/Mandalart'; -import ModifyTextField from '@/common/component/ModifyTextField'; +import { ModifyTextField } from '@/common/component/TextField/modify'; +import { DEFAULT_PLACEHOLDER as MODIFY_PLACEHOLDER } from '@/common/component/TextField/modify/constants'; import CycleDropDown from '@/common/component/CycleDropDown/CycleDropDown'; import { useUpdateSubGoal } from '@/api/domain/edit/hook'; import type { SubGoal, CoreGoal } from '@/page/mandal/types/mandal'; @@ -117,12 +118,7 @@ const HoverContent = ({
e.stopPropagation()}>
e.preventDefault()}> - +
    {subGoals.map((subGoal, index) => (
  • @@ -134,7 +130,7 @@ const HoverContent = ({ variant="todo" value={subGoal.title} onChange={(value) => handleTodoChange(index, value)} - placeholder={`${index + 1}번째 목표를 입력해주세요.`} + placeholder={`${index + 1}번째 ${MODIFY_PLACEHOLDER.todo}`} />
  • ))} diff --git a/src/page/signup/BasicInfoSection/BasicInfoSection.tsx b/src/page/signup/BasicInfoSection/BasicInfoSection.tsx index 338f6b22..cd3b6a39 100644 --- a/src/page/signup/BasicInfoSection/BasicInfoSection.tsx +++ b/src/page/signup/BasicInfoSection/BasicInfoSection.tsx @@ -1,4 +1,4 @@ -import SignupTextField from '@/common/component/SignupTextField'; +import { SignupTextField } from '@/common/component/TextField/signup'; import JobDropDown from '@/page/signup/component/JobDropDown/JobDropDown'; import * as styles from '@/page/signup/BasicInfoSection/BasicInfoSection.css'; import LabeledField from '@/page/signup/component/LabelField/LabelField'; @@ -34,7 +34,7 @@ const BasicInfoSection = ({ - + - + diff --git a/src/page/signup/component/JobDropDown/JobDropDown.tsx b/src/page/signup/component/JobDropDown/JobDropDown.tsx index 363250b5..3d0fb2c3 100644 --- a/src/page/signup/component/JobDropDown/JobDropDown.tsx +++ b/src/page/signup/component/JobDropDown/JobDropDown.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { IcDropdown } from '@/assets/svg'; import * as styles from '@/page/signup/component/JobDropDown/JobDropDown.css'; import JobList from '@/page/signup/component/JobDropDown/JobList'; -import SignupTextField from '@/common/component/SignupTextField'; +import { SignupTextField } from '@/common/component/TextField/signup'; import type { JobItem } from '@/page/signup/component/JobDropDown/type/JobItem'; import type { JobValue } from '@/page/signup/component/JobDropDown/type/JobValue'; import { useGetJobList } from '@/api/domain/signup/hook/useGetJobList'; @@ -72,12 +72,7 @@ const JobDropDown = ({ {!isPlaceHolder && selectedJob.id === jobList[jobList.length - 1].id && (
    - +
    )} diff --git a/src/page/signup/hook/useSignUpForm.ts b/src/page/signup/hook/useSignUpForm.ts index 8c0b1071..bd0d9d51 100644 --- a/src/page/signup/hook/useSignUpForm.ts +++ b/src/page/signup/hook/useSignUpForm.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { validateField } from '@/common/component/SignupTextField/validation'; +import { validateSignupField as validateField } from '@/common/component/TextField/signup/validation'; import type { JobValue } from '@/page/signup/component/JobDropDown/type/JobValue'; const PLACE_HOLDER = '직업을 선택하세요'; diff --git a/src/page/todo/entireTodo/Todo.tsx b/src/page/todo/entireTodo/Todo.tsx index 77950169..4a26fdb6 100644 --- a/src/page/todo/entireTodo/Todo.tsx +++ b/src/page/todo/entireTodo/Todo.tsx @@ -1,13 +1,13 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { FULL_TEXT, TYPING_DURATION, PLACEHOLDER_TEXT } from './constant/typing'; +import { FULL_TEXT, TYPING_DURATION } from './constant/typing'; import * as styles from './Todo.css'; import GradientBackground from '@/common/component/Background/GradientBackground'; import GoButton from '@/common/component/GoButton/GoButton'; import Loading from '@/common/component/Loading/Loading'; -import TextField from '@/common/component/MandalartTextField/MandalartTextField'; +import { MandalartTextField } from '@/common/component/TextField/mandalart'; import useTypingEffect from '@/common/hook/useTypingEffect'; import { useCreateEntireTodo } from '@/api/domain/entireTodo/hook'; import { PATH } from '@/route'; @@ -17,6 +17,7 @@ const Todo = () => { const trimmed = inputText.trim(); const isValid = trimmed.length > 0; const displayedText = useTypingEffect(FULL_TEXT, TYPING_DURATION); + const formRef = useRef(null); const { mutateAsync, isPending } = useCreateEntireTodo(); const navigate = useNavigate(); @@ -47,14 +48,18 @@ const Todo = () => {

    {displayedText}

    - - + { + if (reason === 'enter') { + formRef.current?.requestSubmit(); + } + }} /> diff --git a/src/page/todo/entireTodo/constant/typing.ts b/src/page/todo/entireTodo/constant/typing.ts index ec50867d..75b86937 100644 --- a/src/page/todo/entireTodo/constant/typing.ts +++ b/src/page/todo/entireTodo/constant/typing.ts @@ -1,3 +1,2 @@ export const FULL_TEXT = '66일간 달성할 목표를 입력하고\n만다라트를 시작해보세요!'; export const TYPING_DURATION = 3000; -export const PLACEHOLDER_TEXT = '이루고 싶은 목표를 작성하세요.'; diff --git a/src/page/todo/lowerTodo/component/TodoFields.tsx b/src/page/todo/lowerTodo/component/TodoFields.tsx index 04ac79c7..9f2698ec 100644 --- a/src/page/todo/lowerTodo/component/TodoFields.tsx +++ b/src/page/todo/lowerTodo/component/TodoFields.tsx @@ -2,7 +2,7 @@ import { useRef, forwardRef, useImperativeHandle } from 'react'; import * as styles from '../LowerTodo.css'; -import TextField from '@/common/component/MandalartTextField/MandalartTextField'; +import { MandalartTextField } from '@/common/component/TextField/mandalart'; import CycleDropDown from '@/common/component/CycleDropDown/CycleDropDown'; interface TodoItem { @@ -43,13 +43,6 @@ const TodoFields = forwardRef(function TodoFields( isComposing: isComposingArr.current, })); - const handleCompositionStart = (index: number) => { - isComposingArr.current[index] = true; - }; - const handleCompositionEnd = (index: number) => { - isComposingArr.current[index] = false; - }; - const handleSave = (todo: TodoItem, index: number) => { if (isComposingArr.current[index]) { return; @@ -84,6 +77,12 @@ const TodoFields = forwardRef(function TodoFields( onChange(newValues); }; + const getHandleFieldCommit = (index: number) => (_value: string, reason: 'enter' | 'blur') => { + if (reason === 'enter') { + handleSave(values[index], index); + } + }; + return (
    {values.map((item, index) => ( @@ -94,20 +93,13 @@ const TodoFields = forwardRef(function TodoFields( onChange={(label) => handleCycleChange(index, label as CycleLabel)} />
    - handleTitleChange(index, newValue)} - placeholder="할 일을 입력해주세요" + onCommit={getHandleFieldCommit(index)} disabled={disabled} maxLength={30} - onKeyDown={(e) => { - if (e.key === 'Enter' && !isComposingArr.current[index]) { - handleSave(values[index], index); - } - }} - onCompositionStart={() => handleCompositionStart(index)} - onCompositionEnd={() => handleCompositionEnd(index)} /> ))} diff --git a/src/page/todo/upperTodo/component/SubGoalFields.tsx b/src/page/todo/upperTodo/component/SubGoalFields.tsx index cb1d80e7..7e574fc7 100644 --- a/src/page/todo/upperTodo/component/SubGoalFields.tsx +++ b/src/page/todo/upperTodo/component/SubGoalFields.tsx @@ -1,8 +1,8 @@ import * as styles from '../UpperTodo.css'; import { ORDER_LABELS } from '../constants'; -import { DEFAULT_PLACEHOLDER } from '@/common/component/MandalartTextField/constant/constants'; -import TextField from '@/common/component/MandalartTextField/MandalartTextField'; +import { DEFAULT_PLACEHOLDER } from '@/common/component/TextField/mandalart/constants'; +import { MandalartTextField } from '@/common/component/TextField/mandalart'; interface SubGoalFieldsProps { values: string[]; @@ -20,22 +20,23 @@ const SubGoalFields = ({ values, onChange, idPositions, onEnter }: SubGoalFields onChange(newValues); }; + const getHandleFieldCommit = + (index: number, coreGoalId?: number) => (value: string, reason: 'enter' | 'blur') => { + if (reason === 'enter' && onEnter) { + onEnter(index, value, coreGoalId); + } + }; + return (
    {values.map((value, index) => ( - handleChange(index, val)} + onCommit={getHandleFieldCommit(index, idPositions?.[index]?.id)} placeholder={`${ORDER_LABELS[index]} ${DEFAULT_PLACEHOLDER.subGoal}`} - data-id={idPositions?.[index]?.id?.toString()} - onKeyDown={(e) => { - if (e.key === 'Enter' && onEnter) { - e.preventDefault(); - onEnter(index, e.currentTarget.value, idPositions?.[index]?.id); - } - }} /> ))}