Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions public/svg/ic_small_textdelete.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions src/assets/svg/IcSmallTextdelete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { SVGProps } from 'react';
const SvgIcSmallTextdelete = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" {...props}>
<path
stroke="#E3E4E5"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4.166 15.835 15.833 4.168m-11.667 0 11.667 11.667"
/>
</svg>
);
export default SvgIcSmallTextdelete;
5 changes: 3 additions & 2 deletions src/assets/svg/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
export { default as IcLock } from './IcLock';
export { default as IcTextdelete } from './IcTextdelete';
export { default as IcBigNext } from './IcBigNext';
export { default as IcCheckboxChecked } from './IcCheckboxChecked';
export { default as IcCheckboxDefault } from './IcCheckboxDefault';
export { default as IcDropdown } from './IcDropdown';
export { default as IcLock } from './IcLock';
export { default as IcModalDelete } from './IcModalDelete';
export { default as IcSmallTextdelete } from './IcSmallTextdelete';
export { default as IcTextdelete } from './IcTextdelete';
export { default as IcTooltipDelete } from './IcTooltipDelete';
export { default as IcTriangle } from './IcTriangle';
export { default as Vite } from './Vite';
51 changes: 51 additions & 0 deletions src/common/component/SignupTextField/RenderInputContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';

import IcSmallTextdelete from '@/assets/svg/IcSmallTextdelete';
import IcLock from '@/assets/svg/IcLock';

interface RenderInputContentProps {
fieldState: string;
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
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 === 'error') {
return (
<>
<input {...inputProps} />
{value && !isLocked && (
<button
type="button"
onClick={handleClearClick}
onMouseDown={(e) => e.preventDefault()}
tabIndex={-1}
className={styles.clearButton}
aria-label="입력값 삭제"
>
<IcSmallTextdelete className={styles.iconClass} />
</button>
)}
</>
);
}
if (fieldState === 'locked') {
return (
<div className={styles.inputContent}>
<input {...inputProps} />
<IcLock className={styles.lockIconClass} />
</div>
);
}
return <input {...inputProps} />;
}
122 changes: 122 additions & 0 deletions src/common/component/SignupTextField/SignupTextField.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const MOCK_SIGNUP_DATA = {
name: '신지수?((#((3ㅓ우렁',
email: 'sjsz0811@hanyang.ac.kr',
birth: '2002-08-11',
};
130 changes: 130 additions & 0 deletions src/common/component/SignupTextField/SignupTextField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { SignupTextFieldProps } from './SignupTextField.types';
import * as styles from './SignupTextField.css';
import { validateField } from './validation';
import { formatBirthDate } from '@/common/util/format';
import { useSignupTextFieldState } from './useSignupTextFieldState';
import { RenderInputContent } from './RenderInputContent';

function getFieldState(
isFocused: boolean,
hasValue: boolean,
isHovered: boolean,
error: boolean,
isLocked: boolean,
): keyof typeof styles.fieldVariants {
if (isLocked) {
return 'locked';
}
if (error) {
return 'error';
}
if (isFocused && hasValue) {
return 'typing';
}
if (isFocused && !hasValue) {
return 'clicked';
}
if (!isFocused && hasValue) {
return 'completed';
}
if (isHovered) {
return 'clicked';
}
return 'default';
}

export default function SignupTextField({
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, state.isHovered, !!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 {
ref: inputRef,
type: 'text' as const,
value,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className={error ? styles.errorMessageWrapper : undefined}>
<div
className={[styles.baseClass, styles.fieldVariants[fieldState]].join(' ')}
{...wrapperProps}
>
<RenderInputContent
fieldState={fieldState}
inputProps={inputProps}
value={value}
isLocked={isLocked}
handleClearClick={handleClearClick}
styles={styles}
/>
</div>
{error && <div className={styles.errorMessage}>{error}</div>}
</div>
);
}
13 changes: 13 additions & 0 deletions src/common/component/SignupTextField/SignupTextField.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type SignupTextFieldType = 'name' | 'email' | 'birth' | 'job';

export interface SignupTextFieldProps {
type: SignupTextFieldType;
value: string;
onChange: (value: string) => void;
placeholder?: string;
error?: string;
disabled?: boolean;
onBlur?: () => void;
onFocus?: () => void;
maxLength?: number;
}
5 changes: 5 additions & 0 deletions src/common/component/SignupTextField/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const ERROR_MESSAGES = {
name: '한글/영문 10자 이하로 입력해주세요',
birth: '정확한 생년월일을 입력해주세요',
job: '한글/영문 15자 이하로 입력해주세요',
} as const;
2 changes: 2 additions & 0 deletions src/common/component/SignupTextField/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './SignupTextField';
export * from './SignupTextField.types';
Loading