Skip to content

Commit a12d0df

Browse files
authored
Merge pull request #65 from SOPT-36-NINEDOT/feat/#58/signupTextField
[Feat]: 회원가입 TextField 컴포넌트
2 parents 2ae349c + 9af6b43 commit a12d0df

File tree

13 files changed

+490
-2
lines changed

13 files changed

+490
-2
lines changed

public/svg/ic_small_textdelete.svg

Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { SVGProps } from 'react';
2+
const SvgIcSmallTextdelete = (props: SVGProps<SVGSVGElement>) => (
3+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" {...props}>
4+
<path
5+
stroke="#E3E4E5"
6+
strokeLinecap="round"
7+
strokeLinejoin="round"
8+
strokeWidth={1.5}
9+
d="M4.166 15.835 15.833 4.168m-11.667 0 11.667 11.667"
10+
/>
11+
</svg>
12+
);
13+
export default SvgIcSmallTextdelete;

src/assets/svg/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
export { default as IcLock } from './IcLock';
2-
export { default as IcTextdelete } from './IcTextdelete';
31
export { default as IcBigNext } from './IcBigNext';
42
export { default as IcCheckboxChecked } from './IcCheckboxChecked';
53
export { default as IcCheckboxDefault } from './IcCheckboxDefault';
64
export { default as IcDropdown } from './IcDropdown';
5+
export { default as IcLock } from './IcLock';
76
export { default as IcModalDelete } from './IcModalDelete';
7+
export { default as IcSmallTextdelete } from './IcSmallTextdelete';
8+
export { default as IcTextdelete } from './IcTextdelete';
89
export { default as IcRadioChecked } from './IcRadioChecked';
910
export { default as IcRadioDefault } from './IcRadioDefault';
1011
export { default as IcTooltipDelete } from './IcTooltipDelete';
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React from 'react';
2+
3+
import IcSmallTextdelete from '@/assets/svg/IcSmallTextdelete';
4+
import IcLock from '@/assets/svg/IcLock';
5+
6+
interface RenderInputContentProps {
7+
fieldState: string;
8+
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
9+
value: string;
10+
isLocked: boolean;
11+
handleClearClick: (e: React.MouseEvent) => void;
12+
styles: typeof import('./SignupTextField.css');
13+
}
14+
15+
export function RenderInputContent({
16+
fieldState,
17+
inputProps,
18+
value,
19+
isLocked,
20+
handleClearClick,
21+
styles,
22+
}: RenderInputContentProps) {
23+
if (fieldState === 'typing' || fieldState === 'error') {
24+
return (
25+
<>
26+
<input {...inputProps} />
27+
{value && !isLocked && (
28+
<button
29+
type="button"
30+
onClick={handleClearClick}
31+
onMouseDown={(e) => e.preventDefault()}
32+
tabIndex={-1}
33+
className={styles.clearButton}
34+
aria-label="입력값 삭제"
35+
>
36+
<IcSmallTextdelete className={styles.iconClass} />
37+
</button>
38+
)}
39+
</>
40+
);
41+
}
42+
if (fieldState === 'locked') {
43+
return (
44+
<div className={styles.inputContent}>
45+
<input {...inputProps} />
46+
<IcLock className={styles.lockIconClass} />
47+
</div>
48+
);
49+
}
50+
return <input {...inputProps} />;
51+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { styleVariants, style } from '@vanilla-extract/css';
2+
3+
import { colors } from '@/style/token/color.css';
4+
import { fonts } from '@/style/token/typography.css';
5+
6+
export const baseClass = style({
7+
display: 'flex',
8+
width: '52.2rem',
9+
height: '5rem',
10+
padding: '1.4rem 2rem',
11+
alignItems: 'center',
12+
flexShrink: 0,
13+
borderRadius: '8px',
14+
border: '2px solid transparent',
15+
...fonts.body03,
16+
});
17+
18+
export const fieldVariants = styleVariants({
19+
default: {
20+
border: '2px solid transparent',
21+
background: colors.grey4,
22+
color: colors.grey6,
23+
},
24+
clicked: {
25+
border: `2px solid ${colors.blue06}`,
26+
background: colors.grey3,
27+
color: colors.grey6,
28+
},
29+
typing: {
30+
border: `2px solid ${colors.blue06}`,
31+
background: colors.grey3,
32+
color: colors.grey10,
33+
},
34+
filled: {
35+
border: '2px solid transparent',
36+
background: colors.grey4,
37+
color: colors.grey10,
38+
},
39+
completed: {
40+
border: '2px solid transparent',
41+
background: colors.grey4,
42+
color: colors.grey10,
43+
},
44+
locked: {
45+
border: '2px solid transparent',
46+
background: colors.grey4,
47+
color: colors.grey5,
48+
},
49+
error: {
50+
border: `2px solid ${colors.error01}`,
51+
background: colors.grey4,
52+
color: colors.grey10,
53+
},
54+
});
55+
56+
export const inputContent = style({
57+
display: 'flex',
58+
flex: 1,
59+
alignItems: 'center',
60+
justifyContent: 'space-between',
61+
flexShrink: 0,
62+
});
63+
64+
export const clearButton = style({
65+
display: 'flex',
66+
alignItems: 'center',
67+
justifyContent: 'center',
68+
width: '2rem',
69+
height: '2rem',
70+
background: 'none',
71+
border: 'none',
72+
padding: 0,
73+
cursor: 'pointer',
74+
});
75+
76+
export const iconClass = style({
77+
width: '2rem',
78+
height: '2rem',
79+
flexShrink: 0,
80+
});
81+
82+
export const lockIconClass = style({
83+
width: '2rem',
84+
height: '2rem',
85+
flexShrink: 0,
86+
});
87+
88+
export const errorMessageWrapper = style({
89+
display: 'flex',
90+
width: '52.2rem',
91+
flexDirection: 'column',
92+
alignItems: 'flex-start',
93+
gap: '0.4rem',
94+
});
95+
96+
export const inputBase = style({
97+
display: 'block',
98+
width: '100%',
99+
height: '100%',
100+
background: 'transparent',
101+
border: 'none',
102+
outline: 'none',
103+
padding: 0,
104+
color: 'inherit',
105+
});
106+
107+
const makeInputStyle = (font: typeof fonts.body03) =>
108+
style({
109+
...font,
110+
'::placeholder': {
111+
color: colors.grey6,
112+
...font,
113+
},
114+
});
115+
116+
export const inputStyle = makeInputStyle(fonts.body03);
117+
118+
export const errorMessage = style({
119+
alignSelf: 'stretch',
120+
color: colors.error01,
121+
...fonts.caption02,
122+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const MOCK_SIGNUP_DATA = {
2+
name: '신지수?((#((3ㅓ우렁',
3+
email: 'sjsz0811@hanyang.ac.kr',
4+
birth: '2002-08-11',
5+
};
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type { SignupTextFieldProps } from './SignupTextField.types';
2+
import * as styles from './SignupTextField.css';
3+
import { validateField } from './validation';
4+
import { useSignupTextFieldState } from './useSignupTextFieldState';
5+
import { RenderInputContent } from './RenderInputContent';
6+
7+
import { formatBirthDate } from '@/common/util/format';
8+
9+
function getFieldState(
10+
isFocused: boolean,
11+
hasValue: boolean,
12+
isHovered: boolean,
13+
error: boolean,
14+
isLocked: boolean,
15+
): keyof typeof styles.fieldVariants {
16+
if (isLocked) {
17+
return 'locked';
18+
}
19+
if (error) {
20+
return 'error';
21+
}
22+
if (isFocused && hasValue) {
23+
return 'typing';
24+
}
25+
if (isFocused && !hasValue) {
26+
return 'clicked';
27+
}
28+
if (!isFocused && hasValue) {
29+
return 'completed';
30+
}
31+
if (isHovered) {
32+
return 'clicked';
33+
}
34+
return 'default';
35+
}
36+
37+
export default function SignupTextField({
38+
type,
39+
value,
40+
onChange,
41+
placeholder,
42+
error: externalError,
43+
onBlur,
44+
onFocus,
45+
}: SignupTextFieldProps) {
46+
const {
47+
state,
48+
dispatch,
49+
inputRef,
50+
handleKeyDown,
51+
handleClearClick,
52+
handleWrapperClick,
53+
handleWrapperKeyDown,
54+
} = useSignupTextFieldState({ onChange });
55+
56+
const isLocked = type === 'email';
57+
const error =
58+
externalError ||
59+
(type === 'email' ? undefined : validateField(type as 'name' | 'birth' | 'job', value));
60+
const hasValue = Boolean(value);
61+
const fieldState = getFieldState(state.isFocused, hasValue, state.isHovered, !!error, isLocked);
62+
63+
const wrapperProps = isLocked
64+
? { tabIndex: -1 as const }
65+
: {
66+
role: 'button' as const,
67+
tabIndex: 0 as const,
68+
onClick: handleWrapperClick,
69+
onKeyDown: handleWrapperKeyDown,
70+
onMouseEnter: () => dispatch({ type: 'HOVER_ENTER' }),
71+
onMouseLeave: () => dispatch({ type: 'HOVER_LEAVE' }),
72+
};
73+
74+
function createInputProps() {
75+
return {
76+
ref: inputRef,
77+
type: 'text' as const,
78+
value,
79+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
80+
if (!isLocked) {
81+
if (type === 'birth') {
82+
onChange(formatBirthDate(e.target.value));
83+
} else {
84+
onChange(e.target.value);
85+
}
86+
}
87+
},
88+
onFocus: () => {
89+
dispatch({ type: 'FOCUS' });
90+
onFocus?.();
91+
},
92+
onBlur: () => {
93+
dispatch({ type: 'BLUR' });
94+
onBlur?.();
95+
},
96+
onKeyDown: handleKeyDown,
97+
onCompositionStart: () => dispatch({ type: 'COMPOSE_START' }),
98+
onCompositionEnd: (e: React.CompositionEvent<HTMLInputElement>) => {
99+
dispatch({ type: 'COMPOSE_END' });
100+
if (type === 'name' || type === 'job') {
101+
const filtered = e.currentTarget.value.replace(/[^a-zA-Z---\s]/g, '');
102+
onChange(filtered);
103+
}
104+
},
105+
placeholder: placeholder ?? (type === 'job' ? '정보를 입력해주세요' : placeholder),
106+
disabled: isLocked,
107+
className: [styles.inputBase, styles.inputStyle].join(' '),
108+
};
109+
}
110+
111+
const inputProps = createInputProps();
112+
113+
return (
114+
<div className={error ? styles.errorMessageWrapper : undefined}>
115+
<div
116+
className={[styles.baseClass, styles.fieldVariants[fieldState]].join(' ')}
117+
{...wrapperProps}
118+
>
119+
<RenderInputContent
120+
fieldState={fieldState}
121+
inputProps={inputProps}
122+
value={value}
123+
isLocked={isLocked}
124+
handleClearClick={handleClearClick}
125+
styles={styles}
126+
/>
127+
</div>
128+
{error && <div className={styles.errorMessage}>{error}</div>}
129+
</div>
130+
);
131+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type SignupTextFieldType = 'name' | 'email' | 'birth' | 'job';
2+
3+
export interface SignupTextFieldProps {
4+
type: SignupTextFieldType;
5+
value: string;
6+
onChange: (value: string) => void;
7+
placeholder?: string;
8+
error?: string;
9+
disabled?: boolean;
10+
onBlur?: () => void;
11+
onFocus?: () => void;
12+
maxLength?: number;
13+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const ERROR_MESSAGES = {
2+
name: '한글/영문 10자 이하로 입력해주세요',
3+
birth: '정확한 생년월일을 입력해주세요',
4+
job: '한글/영문 15자 이하로 입력해주세요',
5+
} as const;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './SignupTextField';
2+
export * from './SignupTextField.types';

0 commit comments

Comments
 (0)