3๋
๊ฐ ํ์ฌ์์ ๊ฐ๋ฐํ๋ฉด์ ๋๋ฆ ๊ฒฝํ์ ์์๋ค๊ณ ์๊ฐํ์ต๋๋ค.
ํ์ง๋ง ๋์ด์ผ๋ณด๋ฉด ๊ทธ ๊ฒฝํ์ ์ด๋ฏธ ์ ์ง์ธ ๊ตฌ์กฐ ์์์ ์ฃผ์ด์ง ์ญํ ๋ง ์ํํ์๋ ๊ฒ ๊ฐ์ต๋๋ค.
๋์์ด๋์์ ํ์
๋ฐฉ์์ ์ด๋ฏธ ์ ํํ๋์ด ์์๊ณ , ๋์์ธ ์์คํ
๋ ๊ตฌ์ถ๋ผ ์์ด์ ์์์ ๋ฐ์ ๊ตฌํํ๋ ๋ฐ๋ง ์ง์คํ๋ฉด ๋๊ฑฐ๋ ์.
์๋ฆฌ๋ ์์ฌ ํ๋ฉด
์ด๋ฒ ํ๋ก์ ํธ์์๋ ๋ชจ๋ ๊ฑธ ์ฒ์๋ถํฐ ๋ง๋ค์ด์ผ ํ์ต๋๋ค. ๊ทธ ๊ณผ์ ์์ ๋ง์ ๊ฒ๋ค์ ์๋กญ๊ฒ ๊นจ๋ฌ์๊ณ , ํนํ ๋์์ด๋์์ ํ์
์์ ์ฌ๋ฌ ๋ฐฐ์๋ค์ด ์์์ต๋๋ค!
๊ฐ์๊ธฐ ๋ณด์ด๊ธฐ ์์ํ ๊ฒ๋ค
์ํ์ ๋ํ ์ ์
๋์์ด๋๋๊ณผ ๊ฐ์ "์ํ"๋ผ๋ ๋จ์ด๋ฅผ ์ฌ์ฉํ๊ณ ์์๋๋ฐ, ์ค์ ๋ก๋ ๋ค๋ฅธ ๋ฐฉ์์ ์๊ฐํ๊ณ ์์์ต๋๋ค.
- ๋์์ด๋๋์ด ๋งํ๋ ์ํ๋ ์ฃผ๋ก ์๊ฐ์ ์ธ ๋ณํ — ์๋ฅผ ๋ค์ด ์๊น, ํ
๋๋ฆฌ, ์์ด์ฝ ๊ฐ์ UI ์์๋ค์ด์๊ณ ,
- ์ ๊ฐ ๋งํ๋ ์ํ๋ ๋ฐ์ดํฐ์ ์ํ ํน์ ์ฌ์ฉ์ ์ํธ์์ฉ — ์๋ฅผ ๋ค์ด ์๋ฌ ์ฌ๋ถ, ์
๋ ฅ ์๋ฃ ์ฌ๋ถ, ๋คํธ์ํฌ ์ํ ๊ฐ์ ๊ฒ์ด์์ต๋๋ค.
์๋์์ ์์ธํ ์ค๋ช
ํ๊ฒ ์ง๋ง ๊ฐ๋ฐํ๋ ๊ณตํต ์ปดํฌ๋ํธ๋ก ์ค๋ช
ํด ๋ณด๊ฒ ์ต๋๋ค.
์ฒ์ ์์์๋ "๊ธฐ๋ณธ, ํฌ์ปค์ค, ์๋ฌ, ๋นํ์ฑํ" ์ ๋๋ง ์ ์๋ผ ์์์ด์.
๊ทธ๋ฐ๋ฐ ์ค์ ๋ก ๊ตฌํ์ ๋ค์ด๊ฐ๋ฉด์ ๊ณ ๋ฏผ์ด ์ปค์ก์ต๋๋ค.
- ์ด๋ฉ์ผ ํ๋์์ ์ค๋ฅ ๋ฉ์์ง๊ฐ ๋จ๋ ๋์์ ํฌ์ปค์ค๊ฐ ๊ฐ๋ฉด ์ด๋ป๊ฒ ๋ณด์ฌ์ค์ผ ํ์ง?
- ๋คํธ์ํฌ ์ค๋ฅ๊ฐ ๋ฐ์ํ๋ฉด์ ๋์์ ๋ก๋ฉ ์ํ๋ ์ ์งํด์ผ ํ๋ค๋ฉด?
- ๋น๋ฐ๋ฒํธ ํ๋์์๋ ํธ๋ฒ + ์๋ฌ ์ํ๊ฐ ํจ๊ป ์ค๋ฉด ์ด๋ค UI์ฌ์ผ ํ ๊น?
์ด๋ ๊ฒ ์ํ ์กฐํฉ ๋ง์์ง๊ธฐ ์์ํ์ด์.
ํ์ฌ๋ ์ด๋ฏธ ๋์์ธ ์์คํ
๊ณผ ๊ณตํต ์ปดํฌ๋ํธ๊ฐ ์ ์ ๋ฆฌ๋ผ ์์ด์, ์ด๋ฐ ์กฐํฉ๋ค์ ์ ๊ฒฝ ์ธ ํ์๊ฐ ๊ฑฐ์ ์์๊ฑฐ๋ ์.
ํ์ง๋ง ์ด๋ฒ ํ๋ก์ ํธ์์๋ ๋ชจ๋ ๊ฒ์ ์ง์ ๊ธฐํํ๊ณ , ๋์์ด๋๋๊ณผ ๋
ผ์ํ๊ณ , ๊ตฌํ๊น์ง ํด๋ณด๋ฉด์ "์ํ"๋ฅผ ์ ์ํ๋ค๋๊ฒ ์ผ๋ง๋ ๋ณต์กํ๊ณ ์ฌ์ธํ ์ผ์ธ์ง ์ฒด๊ฐํ ์ ์์์ต๋๋ค.
๊ฐ์ฅ ์ฒ์ ์์๋ ์ํต์ด ์์๋ ํ๋ฉด
๋ก๊ทธ์ธ ํ๋ฉด
ํ๋ก์ ํธ ์ด๊ธฐ ๋์์ด๋๋์ด ๋ก๊ทธ์ธ ํ๋ฉด ์์์ ๊ฐ์ ธ์์ต๋๋ค. ๊น๋ํ๊ณ ์ง๊ด์ ์ธ ๋์์ธ์ด์์ต๋๋ค. ํ์ง๋ง ๊ตฌํ์ ์์ํ๋ฉด์ ์์ฐ์ค๋ฝ๊ฒ ๋ช ๊ฐ์ง ๊ถ๊ธํ ์ ๋ค์ด ์๊ฒผ์ด์.
"๋ก๊ทธ์ธ ์คํจํ์ ๋๋ ์ด๋ป๊ฒ ํ์ํ ๊น์?"
๋์์ด๋๋๋ "์, ๊ทธ๋ฌ๊ฒ์. ์๋ฌ ๋ฉ์์ง๊ฐ ํ์ํ๊ฒ ๋ค์"๋ผ๊ณ ํด์ฃผ์
จ๊ณ ํจ๊ป ๊ณ ๋ฏผํด๋ดค์ต๋๋ค.
๋ก๊ทธ์ธ ์๋ฌ ํ๋ฉด ์์
์์ ์ฝ๋๋ ์ค์ ๊ตฌํ ์ฝ๋์ ์ฐจ์ด๊ฐ ์์ต๋๋ค.
type LoginErrorType = 'INVALID_CREDENTIALS' | 'NETWORK_ERROR' | 'UNKNOWN_ERROR';
interface LoginError {
type: LoginErrorType;
message: string;
field?: 'email' | 'password'; // ๊ฐ๋ฐ ํ๊ฒฝ์์๋ง ์ฌ์ฉ
}
interface LoginState {
status: 'idle' | 'loading' | 'success' | 'error';
error: LoginError | null;
}
// ์๋ฌ ๋ฉ์์ง ์์ ๋ถ๋ฆฌ
const LOGIN_ERROR_MESSAGES = {
INVALID_CREDENTIALS: '์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๋ฅผ ํ์ธํด์ฃผ์ธ์.',
NETWORK_ERROR: '๋คํธ์ํฌ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.',
UNKNOWN_ERROR: '์ ์ ์๋ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.',
} as const;
// ๊ฐ๋ฐ ํ๊ฒฝ์ฉ ์์ธ ๋ฉ์์ง
const DEV_ERROR_MESSAGES = {
INVALID_EMAIL: '์! ๋ฑ๋ก๋์ง ์์ ์ด๋ฉ์ผ์ด๊ฑฐ๋ ์๋ชป ์
๋ ฅํ์ด์.',
INVALID_PASSWORD: '์! ๋น๋ฐ๋ฒํธ๊ฐ ํ๋ ธ์ด์. ๋ค์ ํ ๋ฒ ํ์ธํด์ฃผ์ธ์.',
} as const;
const useLogin = () => {
const [state, setState] = useState<LoginState>({
status: 'idle',
error: null,
});
// ํ๊ฒฝ๋ณ ์๋ฌ ๋ฉ์์ง ์ฒ๋ฆฌ
const getErrorMessage = (type: LoginErrorType, field?: 'email' | 'password'): LoginError => {
const isDevelopment = process.env.NODE_ENV === 'development';
if (isDevelopment && field) {
return {
type,
message: field === 'email' ? DEV_ERROR_MESSAGES.INVALID_EMAIL : DEV_ERROR_MESSAGES.INVALID_PASSWORD,
field,
};
}
return {
type,
message: LOGIN_ERROR_MESSAGES[type],
};
};
const login = async (email: string, password: string) => {
setState({ status: 'loading', error: null });
try {
const user = await authService.findUserByEmail(email);
if (!user) {
setState({
status: 'error',
error: getErrorMessage('INVALID_CREDENTIALS', 'email'),
});
return;
}
const isValidPassword = await authService.validatePassword(email, password);
if (!isValidPassword) {
setState({
status: 'error',
error: getErrorMessage('INVALID_CREDENTIALS', 'password'),
});
return;
}
await authService.setSession(user);
setState({ status: 'success', error: null });
} catch (error) {
setState({
status: 'error',
error: getErrorMessage('NETWORK_ERROR'),
});
}
};
return {
...state,
login,
isLoading: state.status === 'loading',
isSuccess: state.status === 'success',
isError: state.status === 'error',
};
};
๋์์ด๋๋์ ๋ก๊ทธ์ธ ํ๋ฉด์์ ์ด๋ค ๋ฐฉ์์ผ๋ก ์๋ฌ ๋ฉ์์ง๋ฅผ ๋์ธ์ง ๊ณ ๋ฏผํ๊ณ ๊ณ์
จ์ด์.
ํํ ๋ณผ ์ ์๋ "์์ด๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค"์ ๊ฐ์ ๋ชจํธํ ์๋ฌ ๋ฉ์์ง ๋์ , "์ด๋ฉ์ผ์ด ์๋ชป๋์์ต๋๋ค" ๋๋ "๋น๋ฐ๋ฒํธ๊ฐ ์๋ชป๋์์ต๋๋ค"์ฒ๋ผ ๊ตฌ์ฒด์ ์ผ๋ก ์๋ ค์ฃผ๋ ๊ฒ ์ฌ์ฉ์ ์
์ฅ์์ ๋ ์น์ ํ UX๊ฐ ์๋๊น์ ๋ํด ๋
ผ์ํ์์ต๋๋ค.
๊ทธ๋ฆฌ๊ณ ์ ๋ง์ ์น์ฌ์ดํธ๋ค์ด ๋ก๊ทธ์ธ ์คํจ ์ "Incorrect username or password" ์ฒ๋ผ ๋ชจํธํ ๋ฉ์์ง๋ฅผ ์ฌ์ฉํ๋ ๊ฑธ๊น์? ๋ผ๋ ์๋ฌธ์ ๋๋ฌํ์ด์.
๊ด๋ จํด์ ์ฐพ์๋ณธ ๊ฒฐ๊ณผ, tarunbatra.com ๋ธ๋ก๊ทธ์์๋ ๋ณด์์ ์ธ ์ด์ ๋ฅผ ๊ฐ์กฐํ์ต๋๋ค.
๋ก๊ทธ์ธ ์ค๋ฅ ๋ฉ์์ง๋ฅผ ๊ตฌ์ฒด์ ์ผ๋ก ์ ๊ณตํ ๊ฒฝ์ฐ, ์
์์ ์ธ ์ฌ์ฉ์๊ฐ ํด๋น ์ ๋ณด๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์ ํจํ ๊ณ์ ์ ์ถ์ธกํ๊ฑฐ๋ ๋ฐ๋ณต์ ์ผ๋ก ์กฐํฉ์ ์๋ํ ์ ์๊ฒ ๋ฉ๋๋ค.
์๋ฅผ ๋ค์ด, "๋น๋ฐ๋ฒํธ๊ฐ ์๋ชป๋์์ต๋๋ค"๋ผ๋ ๋ฉ์์ง๋ฅผ ๋ณด๋ฉด ํด๋น ์ฌ์ฉ์๋ช
์ ์ ํจํ๋ค๋ ๊ฒ์ ์ ์ ์๊ธฐ ๋๋ฌธ์, ๊ณต๊ฒฉ์์๊ฒ ๋จ์๋ฅผ ์ฃผ๋ ์
์ด์ฃ . ๋ฐ๋ฉด "์์ด๋ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค"์ฒ๋ผ ๋ชจํธํ๊ฒ ์ฒ๋ฆฌํ๋ฉด ๊ณต๊ฒฉ์๋ ์ด๋ค ๋ถ๋ถ์ด ์๋ชป๋์๋์ง ์ ์ ์๊ธฐ ๋๋ฌธ์ ๋ณด์์ ๋ ์์ ํด์ง๋๋ค.
ํ์ง๋ง ๊ทธ ๋น์์ ๋ด๋ถ ๋ฒ ํ ํ
์คํธ๋ฅผ ์งํํ ์์ ์ด์๋ ๋๋ฌผ๋ณ์ ๊ด๊ณ์๋ถ๋ค์ ์์ฒญ์ด ์์๊ณ , ํ
์คํธ ํธ์๋ฅผ ์ํด ๋ก๊ทธ์ธ ์ค๋ฅ ์ ์ด๋ค ํ๋๊ฐ ์๋ชป๋์๋์ง๋ฅผ ๋ถ๋ฆฌํด์ ๋ณด์ฌ์ฃผ๋ UX๋ฅผ ์ฑํํ๊ธฐ๋ก ํ์ต๋๋ค.
์ฌ์ฉ์๊ฐ ์ด๋ค ํ๋์์ ๋ฌธ์ ๊ฐ ์๊ฒผ๋์ง ์ง๊ด์ ์ผ๋ก ์ ์ ์๋๋ก, ํด๋น ํ๋ ๋ฐ๋ก ์๋์ ์๋ฌ ๋ฉ์์ง๋ฅผ ๋์ฐ๋ ๋ฐฉ์์ผ๋ก ํ์ํ๊ณ , ์ค์ ์ฌ์ฉ์ ํ
์คํธ์์๋ ๊ธ์ ์ ์ธ ๋ฐ์์ ์ป์ ์ ์์์ต๋๋ค!
ํธํ๊ฒ ์ฐ๋ ๊ณตํต ์ปดํฌ๋ํธ
์๋ฆฌ๋์์๋ ๋ค์ํ ์
๋ ฅ ํ๋๊ฐ ํ์ํ์ต๋๋ค.
์ด๋ฉ์ผ, ๋น๋ฐ๋ฒํธ, ์ ํ๋ฒํธ, ์ฌ๋ฃ๋ช
, ๋ธ๋๋๋ช
๋ฑ๋ฑ...
์ฒ์์ ๋์์ด๋๋์ด ๋ง๋ ํ
์คํธํ๋๋ ๊ธฐ๋ณธ์ ์ด ๋ชจ์ต์ด์๋๋ฐ, ๊ตฌํํ๋ฉด์ ํจ๊ป ๋ค์ํ ์ํ๋ฅผ ๊ณ ๋ฏผํ๊ฒ ๋์์ต๋๋ค.
"๋น๋ฐ๋ฒํธ ํ๋์์ ์
๋ ฅ ๋ด์ฉ์ ํ์ธํ ์ ์๋ ๋ ๋ชจ์ ์์ด์ฝ์ด ์์ผ๋ฉด ์ข๊ฒ ์ด์."
"๋ง์์! ๊ทธ๋ฆฌ๊ณ ์ฌ๋ฃ ๋ฑ๋กํ ๋ ํ์ ์
๋ ฅ ํญ๋ชฉ์ ๋ผ๋ฒจ์ ๋ณํ(*)๋ฅผ ํ์ํ๋ฉด ์ด๋จ๊น์?"
์ด์ฒ๋ผ ํ๋ํ๋ ๊ธฐ๋ฅ์ด ์ถ๊ฐ๋๋ฉฐ ํ์ํ ์ํ์ ์๋ ๊ธฐํ๊ธ์์ ์ผ๋ก ๋์ด๋ฌ์ต๋๋ค..
์๋ฅผ๋ค์ด ์๋์ฒ๋ผ์. (์ค์ ์๋ฆฌ๋ ๊ตฌํ๊ณผ ์ฐจ์ด๊ฐ ์์ต๋๋ค)
- ๊ธฐ๋ณธ ์ํ 4๊ฐ์ง
- ํธ๋ฒ ์ํ ์ถ๊ฐ → 4 × 2 = 8
- ์ฝ๊ธฐ ์ ์ฉ(read-only) ๊ณ ๋ ค → 8 × 2 = 16
- ํ์/์ ํ ํ์ → 16 × 2 = ์ด 32๊ฐ์ง ์ํ ์กฐํฉ
๊ทธ๋ฐ๋ฐ ๊ฐ์ฅ ์ด๋ ค์ ๋ ๊ฑด ์ด๋ฐ ์กฐํฉ๋ค์ด ๋จ์ํ ์ถ๊ฐ๋ง ๋๋ ๊ฒ ์๋๋ผ, ์๋ก ์ถฉ๋ํ๊ฑฐ๋ ์ฐ์ ์์๊ฐ ์๋ค๋ ์ ์ด์์ด์.
์๋ฅผ ๋ค์ด disabled์ readOnly๋ ๋์์ true์ผ ์ ์์ง๋ง, ์ค์ ๋ก๋ disabled๊ฐ readOnly๋ฅผ ๋ฌด์ํด์.
์ฆ, disabled > readOnly์ธ ๊ฑฐ์ฃ .
์ด๋ฐ ์ํธ ์์กด์ฑ๊ณผ ์ฐ์ ์์๋ ์ฒ์์ ์ ํ ์ธ์งํ์ง ๋ชปํ๊ณ , ๋์์ด๋๋๊ณผ ํจ๊ป ํ์ฐธ์ ๋
ผ์ํ๋ฉด์ ๊ท์น์ ์ ๋ฆฌํด๋๊ฐ์ต๋๋ค:
- disabled > readOnly (์ฐ์ ์์ ์กด์ฌ)
- error์ success๋ ์ํธ ๋ฐฐํ์
- focus๋ ์ฌ์ฉ์์ ์ง์ ์ ์ธ ์ํธ์์ฉ์ผ๋ก๋ง ๋ฐ์
์ธํ ์์ ํ๋ฉด
์์ ์ฝ๋๋ ์ค์ ๊ตฌํ ์ฝ๋์ ์ฐจ์ด๊ฐ ์์ต๋๋ค.
import { forwardRef, useState, useCallback, useMemo, useId } from 'react';
// ๋์์ธ ํ ํฐ
const INPUT_COLORS = {
default: {
border: '#e1e5e9',
background: '#ffffff',
},
focused: {
border: '#00b896', // ๋ธ๋๋ ์ปฌ๋ฌ
background: '#ffffff',
},
error: {
border: '#ff6b6b', // ๋นจ๊ฐ์
background: '#ffffff',
},
disabled: {
border: '#f4f4f4',
background: '#f9f9f9',
},
} as const;
// ์ํ ์ฐ์ ์์ ๋ช
์์ ์ ์
const INPUT_STATE_PRIORITY = {
disabled: 4,
error: 3,
focused: 2,
default: 1,
} as const;
type InputState = keyof typeof INPUT_STATE_PRIORITY;
// Props ํ์
๋ถ๋ฆฌ
interface BaseInputProps {
label?: string;
required?: boolean;
error?: string;
disabled?: boolean;
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
}
interface TextInputProps extends BaseInputProps {
type?: 'text' | 'email' | 'tel';
leftIcon?: React.ReactNode;
rightButton?: React.ReactNode;
}
interface PasswordInputProps extends BaseInputProps {
type: 'password';
showPasswordToggle?: boolean;
}
type InputProps = TextInputProps | PasswordInputProps;
// ์ํ ๊ณ์ฐ ๋ก์ง ๋ถ๋ฆฌ
const getInputState = (isFocused: boolean, error?: string, disabled?: boolean): InputState => {
if (disabled) return 'disabled';
if (error) return 'error';
if (isFocused) return 'focused';
return 'default';
};
const getInputStyles = (state: InputState) => {
const colors = INPUT_COLORS[state];
return {
borderColor: colors.border,
backgroundColor: colors.background,
};
};
const TextInput = forwardRef<HTMLInputElement, InputProps>(({
label,
required = false,
type = 'text',
error,
disabled = false,
placeholder,
value,
onChange,
onFocus,
onBlur,
...props
}, ref) => {
const [showPassword, setShowPassword] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const inputId = useId();
const errorId = useId();
const inputType = useMemo(() => {
if (type === 'password' && !showPassword) return 'password';
return type === 'password' ? 'text' : type;
}, [type, showPassword]);
const currentState = useMemo(() =>
getInputState(isFocused, error, disabled),
[isFocused, error, disabled]
);
const inputStyles = useMemo(() =>
getInputStyles(currentState),
[currentState]
);
// ์ด๋ฒคํธ ํธ๋ค๋ฌ๋ค
const handleFocus = useCallback(() => {
if (disabled) return;
setIsFocused(true);
onFocus?.();
}, [disabled, onFocus]);
const handleBlur = useCallback(() => {
setIsFocused(false);
onBlur?.();
}, [onBlur]);
const togglePasswordVisibility = useCallback(() => {
setShowPassword(prev => !prev);
}, []);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value);
}, [onChange]);
// ํจ์ค์๋ ํ ๊ธ ๋ฒํผ
const passwordToggleButton = type === 'password' && (
<button
type="button"
onClick={togglePasswordVisibility}
className="password-toggle"
aria-label={showPassword ? '๋น๋ฐ๋ฒํธ ์จ๊ธฐ๊ธฐ' : '๋น๋ฐ๋ฒํธ ๋ณด๊ธฐ'}
disabled={disabled}
style={{
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'pointer'
}}
>
{showPassword ? '๐' : '๐๏ธ'}
</button>
);
return (
<div className="text-input">
{label && (
<label htmlFor={inputId} className="input-label">
{label}
{required && (
<span className="required-indicator" aria-label="ํ์" style={{ color: '#ff6b6b' }}>
*
</span>
)}
</label>
)}
<div
className={`input-container input-container--${currentState}`}
style={{
...inputStyles,
border: `1px solid ${inputStyles.borderColor}`,
borderRadius: '8px',
padding: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
{'leftIcon' in props && props.leftIcon && (
<span className="input-icon" aria-hidden="true">
{props.leftIcon}
</span>
)}
<input
ref={ref}
id={inputId}
type={inputType}
disabled={disabled}
placeholder={placeholder}
value={value}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
aria-describedby={error ? errorId : undefined}
aria-invalid={!!error}
style={{
flex: 1,
border: 'none',
outline: 'none',
backgroundColor: 'transparent',
fontSize: '16px',
}}
/>
{passwordToggleButton}
{'rightButton' in props && props.rightButton}
</div>
{error && (
<span
id={errorId}
className="error-message"
role="alert"
style={{
color: '#ff6b6b',
fontSize: '14px',
marginTop: '4px',
display: 'block',
}}
>
{error}
</span>
)}
</div>
);
});
TextInput.displayName = 'TextInput';
// ์ฌ์ฉ ์์
const LoginForm = () => {
const [formData, setFormData] = useState({
email: '',
password: '',
});
const { login, isLoading, error, status } = useLogin();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await login(formData.email, formData.password);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: '400px', margin: '0 auto' }}>
<TextInput
label="์ด๋ฉ์ผ"
type="email"
required
value={formData.email}
onChange={(email) => setFormData(prev => ({ ...prev, email }))}
error={error?.field === 'email' ? error.message : undefined}
placeholder="์ด๋ฉ์ผ์ ์
๋ ฅํด์ฃผ์ธ์"
disabled={isLoading}
/>
<div style={{ marginTop: '16px' }}>
<TextInput
label="๋น๋ฐ๋ฒํธ"
type="password"
required
value={formData.password}
onChange={(password) => setFormData(prev => ({ ...prev, password }))}
error={error?.field === 'password' ? error.message : undefined}
placeholder="๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์"
disabled={isLoading}
showPasswordToggle
/>
</div>
<button
type="submit"
disabled={isLoading}
style={{
width: '100%',
padding: '12px',
marginTop: '24px',
backgroundColor: isLoading ? '#cccccc' : '#00b896',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
cursor: isLoading ? 'not-allowed' : 'pointer',
}}
>
{isLoading ? '๋ก๊ทธ์ธ ์ค...' : '๋ก๊ทธ์ธ'}
</button>
{error && !error.field && (
<div style={{
marginTop: '16px',
padding: '12px',
backgroundColor: '#ffebee',
borderRadius: '8px',
color: '#ff6b6b'
}}>
{error.message}
</div>
)}
</form>
);
};
"๋นํ์ฑํ ์ํ์ผ ๋๋ ํ์์ผ๋ก, ์๋ฌ ์ํ์ผ ๋๋ ๋นจ๊ฐ์ ํ
๋๋ฆฌ๋ก ํ๋ฒํ๊ฒ ๊ฐ๊ณ ํฌ์ปค์ค ์ํ์์๋ ๋ธ๋๋ ์ปฌ๋ฌ์ธ ์ด๋ก์์ผ๋ก ๊ฐ์กฐํ๋ฉด ์ข๊ฒ ์ด์"๋ผ๋ ์๊ฒฌ์ผ๋ก ์ผ์นํ์ต๋๋ค.
์๋ ๋ฅด๊ธฐ ๊ธฐ๋ก์ ์๊ฐํ
์๋ฆฌ๋์ ํต์ฌ ๊ธฐ๋ฅ ์ค ํ๋๊ฐ ๋ฐ๋ ค๊ฒฌ์ ์๋ ๋ฅด๊ธฐ ๋ฐ์์ ๊ธฐ๋กํ๊ณ ๋ถ์ํ๋ ๊ฑด๋ฐ, ์ด ๋ถ๋ถ์์ ์ ๋ง ๋ง์ ๋ํ๊ฐ ์ค๊ฐ์ต๋๋ค.
์๊ฐํ ์์ ํ๋ฉด
ํ์ฌ ๊ตฌํ๋ ์ํ์๋ ๋ค๋ฅด์ง๋ง ์๋์๊ฐ์ ๊ณ ๋ฏผ์ ํ์์ด์.
"์๋ ๋ฅด๊ธฐ ์ ๋๋ฅผ ์ด๋ป๊ฒ ํํํ๋ฉด ์ข์๊น์?"
"์๊น๋ก ๊ตฌ๋ถํ๋ฉด ์ด๋จ๊น์? ๋นจ๊ฐ์์ ์ฌํจ, ์ฃผํฉ์์ ๋ณดํต, ์ด๋ก์์ ๋ฎ์?"
"์์ฝ์ด ์๋ ์ฌ์ฉ์๋ ๊ณ ๋ คํด์ผ ํ ๊ฒ ๊ฐ์์. ์๊น๊ณผ ํจ๊ป ํ
์คํธ๋ ์์ด์ฝ๋ ๋ฃ์ผ๋ฉด ์ด๋จ๊น์?"
์์ ์ฝ๋๋ ์ค์ ๊ตฌํ ์ฝ๋์ ์ฐจ์ด๊ฐ ์์ต๋๋ค.
// ๋์์ธ ํ ํฐ ์ ์
const COLORS = {
success: {
main: '#00b896',
background: '#e6f8f5',
light: '#b3f0e6',
},
warning: {
main: '#ffa726',
background: '#fff3e0',
light: '#ffd699',
},
error: {
main: '#ff6b6b',
background: '#ffebee',
light: '#ffb3b3',
},
gray: {
600: '#666666',
500: '#999999',
100: '#f5f5f5',
},
} as const;
// ์๋ ๋ฅด๊ธฐ ๋ ๋ฒจ ํ์
type AllergyLevel = 'low' | 'medium' | 'high';
// ์๋ ๋ฅด๊ธฐ ๋ฐ์ดํฐ ์ธํฐํ์ด์ค
interface AllergyData {
level: AllergyLevel;
percentage: number;
ingredients: string[];
timestamp?: Date;
}
// ์์ด์ฝ ์ปดํฌ๋ํธ๋ค (์ค์ ๋ก๋ ์์ด์ฝ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ)
const CheckCircleIcon = ({ size = 16 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
);
const AlertCircleIcon = ({ size = 16 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
);
const XCircleIcon = ({ size = 16 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11H7v-2h10v2z"/>
</svg>
);
// ์๋ ๋ฅด๊ธฐ ๋ ๋ฒจ ์ค์
interface AllergyLevelConfig {
color: string;
backgroundColor: string;
text: string;
icon: React.ReactNode;
severity: number;
description: string; // ์ ๊ทผ์ฑ์ ์ํ ์ค๋ช
์ถ๊ฐ
}
const ALLERGY_LEVEL_CONFIG: Record<AllergyLevel, AllergyLevelConfig> = {
low: {
color: COLORS.success.main,
backgroundColor: COLORS.success.background,
text: '์๋ ๋ฅด๊ธฐ ๋ฎ์',
icon: <CheckCircleIcon size={16} />,
severity: 1,
description: '์๋ ๋ฅด๊ธฐ ๋ฐ์์ด ๋ฎ์ ์์ค์
๋๋ค',
},
medium: {
color: COLORS.warning.main,
backgroundColor: COLORS.warning.background,
text: '์๋ ๋ฅด๊ธฐ ๋ณดํต',
icon: <AlertCircleIcon size={16} />,
severity: 2,
description: '์๋ ๋ฅด๊ธฐ ๋ฐ์์ด ๋ณดํต ์์ค์
๋๋ค',
},
high: {
color: COLORS.error.main,
backgroundColor: COLORS.error.background,
text: '์๋ ๋ฅด๊ธฐ ๋์',
icon: <XCircleIcon size={16} />,
severity: 3,
description: '์๋ ๋ฅด๊ธฐ ๋ฐ์์ด ๋์ ์์ค์
๋๋ค',
},
} as const;
// Props ์ธํฐํ์ด์ค
interface AllergyItemProps {
level: AllergyLevel;
percentage: number;
ingredients?: string[];
showDetails?: boolean;
className?: string;
onToggleDetails?: () => void;
}
// ํผ์ผํฐ์ง ํฌ๋งทํ
ํจ์
const formatPercentage = (value: number): string => {
return `${Math.round(value)}%`;
};
// ๋ฉ์ธ ์ปดํฌ๋ํธ
export const AllergyItem: React.FC<AllergyItemProps> = ({
level,
percentage,
ingredients = [],
showDetails = false,
className = '',
onToggleDetails,
}) => {
const config = ALLERGY_LEVEL_CONFIG[level];
return (
<div
className={`allergy-item ${className}`}
style={{
backgroundColor: config.backgroundColor,
border: `1px solid ${config.color}20`, // 20% ํฌ๋ช
๋
borderRadius: '12px',
padding: '16px',
marginBottom: '8px',
transition: 'all 0.2s ease', // ๋ถ๋๋ฌ์ด ์ ํ
}}
role="article"
aria-label={`${config.description}, ${formatPercentage(percentage)}`}
>
<div
className="allergy-header"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: showDetails && ingredients.length > 0 ? '12px' : '0',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
className="allergy-icon"
style={{ color: config.color, display: 'flex', alignItems: 'center' }}
aria-hidden="true"
>
{config.icon}
</span>
<span
className="allergy-text"
style={{
color: config.color,
fontWeight: '600',
fontSize: '14px',
}}
>
{config.text}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
className="allergy-percentage"
style={{
fontWeight: '700',
fontSize: '16px',
color: config.color,
}}
>
{formatPercentage(percentage)}
</span>
{ingredients.length > 0 && (
<button
onClick={onToggleDetails}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
color: config.color,
fontSize: '12px',
transition: 'background-color 0.2s ease',
}}
aria-expanded={showDetails}
aria-label={showDetails ? '์ธ๋ถ์ฌํญ ์จ๊ธฐ๊ธฐ' : '์ธ๋ถ์ฌํญ ๋ณด๊ธฐ'}
>
{showDetails ? 'โฒ' : 'โผ'}
</button>
)}
</div>
</div>
{/* ์์ฝ ์ฌ์ฉ์๋ฅผ ์ํ ์ถ๊ฐ ์๊ฐ์ ์์ */}
<div
style={{
width: '100%',
height: '4px',
backgroundColor: `${config.color}30`,
borderRadius: '2px',
marginBottom: showDetails && ingredients.length > 0 ? '12px' : '0',
overflow: 'hidden',
}}
>
<div
style={{
width: `${percentage}%`,
height: '100%',
backgroundColor: config.color,
borderRadius: '2px',
transition: 'width 0.3s ease',
}}
role="progressbar"
aria-valuenow={percentage}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`์๋ ๋ฅด๊ธฐ ์์ค ${formatPercentage(percentage)}`}
/>
</div>
{showDetails && ingredients.length > 0 && (
<div className="allergy-details">
<h4
style={{
margin: '0 0 8px 0',
fontSize: '12px',
color: COLORS.gray[600],
fontWeight: '600',
}}
>
๊ด๋ จ ์ฑ๋ถ ({ingredients.length}๊ฐ)
</h4>
<ul
className="allergy-ingredients"
style={{
margin: 0,
padding: 0,
listStyle: 'none',
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
}}
>
{ingredients.map((ingredient, index) => (
<li
key={`${ingredient}-${index}`}
className="allergy-ingredient"
style={{
backgroundColor: `${config.color}15`,
color: config.color,
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '500',
border: `1px solid ${config.color}30`,
}}
>
{ingredient}
</li>
))}
</ul>
</div>
)}
</div>
);
};
// ์๋ ๋ฅด๊ธฐ ๋ชฉ๋ก ์ปดํฌ๋ํธ
interface AllergyListProps {
allergies: AllergyData[];
title?: string;
}
export const AllergyList: React.FC<AllergyListProps> = ({
allergies,
title = "์๋ ๋ฅด๊ธฐ ๋ถ์ ๊ฒฐ๊ณผ"
}) => {
const [expandedItems, setExpandedItems] = React.useState<Set<number>>(new Set());
const toggleDetails = (index: number) => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
// ๋ฐ์ดํฐ ์ ๋ ฌ (์ฌ๊ฐ๋ ์)
const sortedAllergies = React.useMemo(() => {
return [...allergies].sort((a, b) => {
const configA = ALLERGY_LEVEL_CONFIG[a.level];
const configB = ALLERGY_LEVEL_CONFIG[b.level];
return configB.severity - configA.severity; // ์ฌ๊ฐํ ๊ฒ๋ถํฐ
});
}, [allergies]);
if (allergies.length === 0) {
return (
<div style={{
textAlign: 'center',
padding: '40px 20px',
color: COLORS.gray[500],
}}>
์๋ ๋ฅด๊ธฐ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.
</div>
);
}
return (
<div className="allergy-list" style={{ maxWidth: '600px', margin: '0 auto' }}>
<h3 style={{
fontSize: '18px',
fontWeight: '700',
marginBottom: '16px',
color: COLORS.gray[600],
}}>
{title}
</h3>
{sortedAllergies.map((allergy, index) => (
<AllergyItem
key={index}
level={allergy.level}
percentage={allergy.percentage}
ingredients={allergy.ingredients}
showDetails={expandedItems.has(index)}
onToggleDetails={() => toggleDetails(index)}
/>
))}
</div>
);
};
// ์ฌ์ฉ ์์
export const AllergyExample = () => {
const sampleData: AllergyData[] = [
{
level: 'high',
percentage: 85,
ingredients: ['๋ญ๊ณ ๊ธฐ', '์', '์ฅ์์'],
},
{
level: 'medium',
percentage: 45,
ingredients: ['์ฐ์ด', '๊ณ ๊ตฌ๋ง'],
},
{
level: 'low',
percentage: 15,
ingredients: ['๋น๊ทผ', '๋ธ๋ก์ฝ๋ฆฌ', '์ฌ๊ณผ'],
},
];
return <AllergyList allergies={sampleData} title="์ฐ๋ฆฌ ๊ฐ์์ง ์๋ ๋ฅด๊ธฐ ๋ถ์" />;
};
"์๋ณ ํต๊ณ๋ฅผ ๋ณด์ฌ์ค ๋ ๊ทธ๋ํ๋ก ํํํ๋ฉด ๋ ์ง๊ด์ ์ผ ๊ฒ ๊ฐ์์"๋ผ๋ ๋์์ด๋๋์ ์ ์์ ์ด๋์ ๋ ํ๋ฉด์ด ๋ณ๊ฒฝ๋์์ต๋๋ค!
๋ง์น๋ฉฐ
์งํํ๋ฉฐ ๊ณ ๋ฏผํ ๋ํ
์ผ๋ค์ ํ์ฌ์์ ์ด๋ฏธ ๋์์ธ ์์คํ
์ด๋ ๊ณตํต ์ปดํฌ๋ํธ์ ๋ฐ์๋์ด ์์ด์, ๋ณ๋ค๋ฅธ ๊ณ ๋ฏผ ์์ด ๊ฐ์ ธ๋ค ์ฐ๊ธฐ๋ง ํ๋ ๊ฒ๋ค์ด์์ด์.
์ด๋ฒ ํ๋ก์ ํธ์์๋ ๊ทธ๋ฐ ์์๋ค์ ์ง์ ๊ธฐํํ๊ณ , ๋์์ธ์ ๋
น์ฌ๋ด๊ณ , ์ปดํฌ๋ํธ๋ก ๊ตฌํํ๋ ๊ณผ์ ์ ํ๋ํ๋ ๊ฒฝํํด๋ณด๋, ๊ฒ์ผ๋ก๋ง ์๊ณ ์์๋ UI ๊ตฌ์ฑ ์์๋ค์ด ์ผ๋ง๋ ๋ง์ ์๋์ ๊ณ ๋ฏผ ์์์ ๋ง๋ค์ด์ก๋์ง๋ฅผ ์์ผ ๋๋ ์ ์์์ด์.
๋ฌด์๋ณด๋ค ๊ทธ๋ฐ ๊ณผ์ ์ด ์๊ฐ๋ณด๋ค ๊ฝค ์ฌ๋ฐ์์ต๋๋ค!
๊ธ ์ด๋ฐ์ ์ด์ผ๊ธฐํ๋ ‘์ํ’์ ๋ํ ์ ์๋ฅผ ์คํฐ๋์๋ค๊ณผ ํจ๊ป ๊ณ ๋ฏผํ๊ณ ์ฐพ์๋ณด๋ ์ค, ์๋์ ๋ธ๋ก๊ทธ ๊ธ์ ์ ํ๊ฒ ๋์๊ณ ํฐ ๋์์ ๋ฐ์์ต๋๋ค. ๐ The Sorry State of States - velog.io/@tap_kim
์ ํฌ๊ฐ ๊ฒช๊ณ ์๋ ๊ณ ๋ฏผ๊ณผ ๊ฐ์ ๋ด์ฉ์ ๋ณด๋ฉฐ ์ ๋ง ๋ฐ๊ฐ์ ๊ณ ,
๋๋ถ์ ๋ง์ฐํ๋ ๋ฌธ์ ๋ค์ ์กฐ๊ธ ๋ ๋น ๋ฅด๊ฒ ์ ๋ฆฌํ๊ณ , ๋ช
ํํ ๊ธฐ์ค์ ์ธ์ธ ์ ์์์ต๋๋ค.