v7.0.0-beta.1 release
This commit is contained in:
commit
6595fd0eb5
3967 files changed
+1875383
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface AProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {}
|
||||
|
||||
/**
|
||||
* 기본 a(링크) 컴포넌트
|
||||
*/
|
||||
export const A: React.FC<AProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
// G7Core 전역 객체의 스타일 헬퍼 접근
|
||||
const G7Core = () => (window as any).G7Core;
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'success';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 버튼 컴포넌트
|
||||
*/
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
...props
|
||||
}, ref) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center';
|
||||
|
||||
// G7Core.style.mergeClasses를 사용하여 충돌하는 Tailwind 클래스 병합
|
||||
const mergedClassName = G7Core()?.style?.mergeClasses?.(baseClasses, className)
|
||||
?? `${baseClasses} ${className}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={mergedClassName}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 체크박스 컴포넌트
|
||||
*
|
||||
* forwardRef를 사용하여 ref 전달을 지원합니다.
|
||||
* indeterminate 상태 등을 설정할 때 ref가 필요합니다.
|
||||
*/
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ label, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface CodeProps extends React.HTMLAttributes<HTMLElement> {}
|
||||
|
||||
/**
|
||||
* 기본 code 컴포넌트
|
||||
*
|
||||
* 인라인 코드 또는 코드 스니펫을 표시하기 위한 기본 컴포넌트입니다.
|
||||
* HTML <code> 태그를 래핑합니다.
|
||||
*/
|
||||
export const Code: React.FC<CodeProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<code
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface DivProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 div 컴포넌트
|
||||
*/
|
||||
export const Div = React.forwardRef<HTMLDivElement, DivProps>(({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { forwardRef, useRef, useCallback, useState } from 'react';
|
||||
import { Div } from './Div';
|
||||
import { Span } from './Span';
|
||||
import { Svg } from './Svg';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
|
||||
export interface FileInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | 'onError'> {
|
||||
/** 허용되는 파일 확장자 (예: ".zip,.pdf") */
|
||||
accept?: string;
|
||||
/** 최대 파일 크기 (MB) */
|
||||
maxSize?: number;
|
||||
/** 파일 선택 시 콜백 */
|
||||
onChange?: (file: File | null) => void;
|
||||
/** 에러 발생 시 콜백 */
|
||||
onError?: (error: string) => void;
|
||||
/** 버튼 텍스트 */
|
||||
buttonText?: string;
|
||||
/** 빈 상태 텍스트 */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 입력 컴포넌트
|
||||
*
|
||||
* ZIP 파일 등의 파일 업로드를 처리하며, 파일 크기 검증을 지원합니다.
|
||||
*/
|
||||
export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(({
|
||||
accept,
|
||||
maxSize,
|
||||
onChange,
|
||||
onError,
|
||||
buttonText = 'Browse',
|
||||
placeholder = 'No file selected',
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const internalRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef;
|
||||
const [fileName, setFileName] = useState<string>('');
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!disabled && inputRef.current) {
|
||||
inputRef.current.click();
|
||||
}
|
||||
}, [disabled, inputRef]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
|
||||
if (!file) {
|
||||
setFileName('');
|
||||
onChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 크기 검증 (MB 단위)
|
||||
if (maxSize && file.size > maxSize * 1024 * 1024) {
|
||||
const errorMsg = `File size exceeds ${maxSize}MB limit`;
|
||||
onError?.(errorMsg);
|
||||
// 입력 초기화
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
setFileName('');
|
||||
return;
|
||||
}
|
||||
|
||||
setFileName(file.name);
|
||||
onChange?.(file);
|
||||
}, [maxSize, onChange, onError, inputRef]);
|
||||
|
||||
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
setFileName('');
|
||||
onChange?.(null);
|
||||
}, [onChange, inputRef]);
|
||||
|
||||
return (
|
||||
<Div className={`flex items-center gap-2 ${className}`}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleChange as any}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
{...props}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<Span className="flex-1 text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{fileName || placeholder}
|
||||
</Span>
|
||||
{fileName && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label="Clear file"
|
||||
>
|
||||
<Svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</Svg>
|
||||
</Button>
|
||||
)}
|
||||
</Div>
|
||||
);
|
||||
});
|
||||
|
||||
FileInput.displayName = 'FileInput';
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface FooterProps extends React.HTMLAttributes<HTMLElement> {
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 footer 컴포넌트
|
||||
*/
|
||||
export const Footer = React.forwardRef<HTMLElement, FooterProps>(({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<footer
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</footer>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface FormProps extends React.FormHTMLAttributes<HTMLFormElement> {}
|
||||
|
||||
/**
|
||||
* 기본 form 컴포넌트
|
||||
*/
|
||||
export const Form: React.FC<FormProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<form
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface H1Props extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
/**
|
||||
* 기본 h1 제목 컴포넌트
|
||||
*/
|
||||
export const H1: React.FC<H1Props> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<h1
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface H2Props extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
/**
|
||||
* 기본 h2 제목 컴포넌트
|
||||
*/
|
||||
export const H2: React.FC<H2Props> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<h2
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface H3Props extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
/**
|
||||
* 기본 h3 제목 컴포넌트
|
||||
*/
|
||||
export const H3: React.FC<H3Props> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<h3
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface H4Props extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
/**
|
||||
* 기본 h4 제목 컴포넌트
|
||||
*/
|
||||
export const H4: React.FC<H4Props> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<h4
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface HeaderProps extends React.HTMLAttributes<HTMLElement> {
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 header 컴포넌트
|
||||
*/
|
||||
export const Header = React.forwardRef<HTMLElement, HeaderProps>(({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface HrProps extends React.HTMLAttributes<HTMLHRElement> {
|
||||
ref?: React.Ref<HTMLHRElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 hr (수평선) 컴포넌트
|
||||
*/
|
||||
export const Hr = React.forwardRef<HTMLHRElement, HrProps>(({
|
||||
className = '',
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<hr
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface IProps extends React.HTMLAttributes<HTMLElement> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* I 기본 컴포넌트
|
||||
*
|
||||
* HTML <i> 태그를 래핑한 기본 컴포넌트입니다.
|
||||
* 주로 아이콘 폰트 (FontAwesome 등)와 함께 사용됩니다.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <I className="fas fa-user" />
|
||||
* <I className="fas fa-home" />
|
||||
* ```
|
||||
*/
|
||||
export const I: React.FC<IProps> = ({ className = '', children, style, ...props }) => {
|
||||
return (
|
||||
<i className={className} style={style} {...props}>
|
||||
{children}
|
||||
</i>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import { IconName, IconStyle, IconSize, iconNameMap } from './IconTypes';
|
||||
|
||||
export interface IconProps extends Omit<React.HTMLAttributes<HTMLElement>, 'style'> {
|
||||
/**
|
||||
* 아이콘 이름 (Font Awesome 클래스명 또는 IconName enum)
|
||||
*
|
||||
* @example
|
||||
* // 문자열로 직접 지정
|
||||
* <Icon name="fa-user" />
|
||||
*
|
||||
* // enum 사용 (타입 안전)
|
||||
* <Icon name={IconName.User} />
|
||||
*/
|
||||
name: string | IconName;
|
||||
|
||||
/**
|
||||
* 아이콘 스타일 (Font Awesome 5/6)
|
||||
*
|
||||
* - solid: fas (기본값)
|
||||
* - regular: far
|
||||
* - light: fal
|
||||
* - duotone: fad
|
||||
* - brands: fab
|
||||
*/
|
||||
iconStyle?: IconStyle;
|
||||
|
||||
/**
|
||||
* CSS 스타일 객체 (HTML style 속성)
|
||||
*/
|
||||
style?: React.CSSProperties;
|
||||
|
||||
/**
|
||||
* 아이콘 크기
|
||||
*
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: IconSize;
|
||||
|
||||
/**
|
||||
* 색상 (Tailwind 클래스 또는 CSS 색상)
|
||||
*/
|
||||
color?: string;
|
||||
|
||||
/**
|
||||
* 회전 애니메이션 활성화
|
||||
*/
|
||||
spin?: boolean;
|
||||
|
||||
/**
|
||||
* 펄스 애니메이션 활성화 (8단계 회전)
|
||||
*/
|
||||
pulse?: boolean;
|
||||
|
||||
/**
|
||||
* 고정 너비 (아이콘 정렬 시 유용)
|
||||
*/
|
||||
fixedWidth?: boolean;
|
||||
|
||||
/**
|
||||
* 접근성 레이블 (자동 생성 가능)
|
||||
*/
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font Awesome 아이콘 컴포넌트
|
||||
*
|
||||
* Font Awesome 5/6 버전과 호환되며 다양한 props를 지원합니다.
|
||||
*
|
||||
* @example
|
||||
* // 기본 사용
|
||||
* <Icon name="fa-user" />
|
||||
*
|
||||
* // enum 사용 (타입 안전)
|
||||
* <Icon name={IconName.Search} />
|
||||
*
|
||||
* // 스타일 및 크기 지정
|
||||
* <Icon name={IconName.Heart} style="regular" size="2x" />
|
||||
*
|
||||
* // 애니메이션
|
||||
* <Icon name={IconName.Spinner} spin />
|
||||
* <Icon name={IconName.CircleNotch} pulse />
|
||||
*
|
||||
* // 색상 지정
|
||||
* <Icon name={IconName.Star} color="text-yellow-500" />
|
||||
*
|
||||
* // 접근성
|
||||
* <Icon name={IconName.User} ariaLabel="사용자 프로필" />
|
||||
*/
|
||||
export const Icon: React.FC<IconProps> = ({
|
||||
name,
|
||||
iconStyle = 'solid',
|
||||
size,
|
||||
color,
|
||||
spin = false,
|
||||
pulse = false,
|
||||
fixedWidth = false,
|
||||
ariaLabel,
|
||||
className = '',
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
// Font Awesome 스타일 클래스 매핑
|
||||
const styleClassMap: Record<IconStyle, string> = {
|
||||
solid: 'fas',
|
||||
regular: 'far',
|
||||
light: 'fal',
|
||||
duotone: 'fad',
|
||||
brands: 'fab',
|
||||
};
|
||||
|
||||
// 아이콘 이름 처리: iconNameMap을 사용하여 Lucide 스타일 이름을 FontAwesome으로 변환
|
||||
const rawName = typeof name === 'string' ? name : String(name);
|
||||
const mappedName = iconNameMap[rawName];
|
||||
// 매핑된 이름이 있으면 사용, 없으면 원본 사용 후 'fa-' 접두사 제거
|
||||
const iconName = mappedName
|
||||
? String(mappedName).replace(/^fa-/, '')
|
||||
: rawName.replace(/^fa-/, '');
|
||||
|
||||
// 클래스 조합
|
||||
const classes = [
|
||||
styleClassMap[iconStyle],
|
||||
`fa-${iconName}`,
|
||||
size && `fa-${size}`,
|
||||
spin && 'fa-spin',
|
||||
pulse && 'fa-pulse',
|
||||
fixedWidth && 'fa-fw',
|
||||
color,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
// 접근성: ariaLabel이 없으면 name에서 생성
|
||||
const accessibilityLabel = ariaLabel || iconName.replace(/-/g, ' ');
|
||||
|
||||
return (
|
||||
<i
|
||||
className={classes}
|
||||
style={style}
|
||||
aria-label={accessibilityLabel}
|
||||
role="img"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Font Awesome 아이콘 스타일
|
||||
*/
|
||||
export type IconStyle = 'solid' | 'regular' | 'light' | 'duotone' | 'brands';
|
||||
|
||||
/**
|
||||
* Font Awesome 아이콘 크기
|
||||
*/
|
||||
export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2x' | '3x' | '4x' | '5x';
|
||||
|
||||
/**
|
||||
* 자주 사용되는 Font Awesome 아이콘 이름
|
||||
*
|
||||
* @example
|
||||
* // 사용 예시
|
||||
* <Icon name="user" />
|
||||
* <Icon name="search" />
|
||||
* <Icon name="shopping-cart" />
|
||||
*/
|
||||
export enum IconName {
|
||||
// 사용자 및 계정
|
||||
User = 'fa-user',
|
||||
Users = 'fa-users',
|
||||
UserCircle = 'fa-user-circle',
|
||||
UserPlus = 'fa-user-plus',
|
||||
UserMinus = 'fa-user-minus',
|
||||
UserShield = 'fa-user-shield',
|
||||
|
||||
// 액션 아이콘
|
||||
Search = 'fa-search',
|
||||
Edit = 'fa-edit',
|
||||
Pencil = 'fa-pencil-alt',
|
||||
Trash = 'fa-trash',
|
||||
Save = 'fa-save',
|
||||
Plus = 'fa-plus',
|
||||
Minus = 'fa-minus',
|
||||
Check = 'fa-check',
|
||||
Times = 'fa-times',
|
||||
Download = 'fa-download',
|
||||
Upload = 'fa-upload',
|
||||
Copy = 'fa-copy',
|
||||
ExternalLink = 'fa-external-link-alt',
|
||||
|
||||
// 네비게이션
|
||||
Home = 'fa-home',
|
||||
ChevronLeft = 'fa-chevron-left',
|
||||
ChevronRight = 'fa-chevron-right',
|
||||
ChevronUp = 'fa-chevron-up',
|
||||
ChevronDown = 'fa-chevron-down',
|
||||
AngleLeft = 'fa-angle-left',
|
||||
AngleRight = 'fa-angle-right',
|
||||
AngleUp = 'fa-angle-up',
|
||||
AngleDown = 'fa-angle-down',
|
||||
ArrowUp = 'fa-arrow-up',
|
||||
ArrowDown = 'fa-arrow-down',
|
||||
ArrowLeft = 'fa-arrow-left',
|
||||
ArrowRight = 'fa-arrow-right',
|
||||
Bars = 'fa-bars',
|
||||
EllipsisVertical = 'fa-ellipsis-v',
|
||||
EllipsisHorizontal = 'fa-ellipsis-h',
|
||||
|
||||
// 상태 아이콘
|
||||
CheckCircle = 'fa-check-circle',
|
||||
TimesCircle = 'fa-times-circle',
|
||||
ExclamationCircle = 'fa-exclamation-circle',
|
||||
ExclamationTriangle = 'fa-exclamation-triangle',
|
||||
InfoCircle = 'fa-info-circle',
|
||||
QuestionCircle = 'fa-question-circle',
|
||||
XCircle = 'fa-times-circle',
|
||||
|
||||
// 이커머스
|
||||
ShoppingCart = 'fa-shopping-cart',
|
||||
ShoppingBag = 'fa-shopping-bag',
|
||||
CreditCard = 'fa-credit-card',
|
||||
Tag = 'fa-tag',
|
||||
Tags = 'fa-tags',
|
||||
|
||||
// 파일 및 문서
|
||||
File = 'fa-file',
|
||||
FileAlt = 'fa-file-alt',
|
||||
Folder = 'fa-folder',
|
||||
FolderOpen = 'fa-folder-open',
|
||||
|
||||
// 통신
|
||||
Envelope = 'fa-envelope',
|
||||
Phone = 'fa-phone',
|
||||
Comment = 'fa-comment',
|
||||
Comments = 'fa-comments',
|
||||
|
||||
// 설정
|
||||
Cog = 'fa-cog',
|
||||
Cogs = 'fa-cogs',
|
||||
Settings = 'fa-cog',
|
||||
Wrench = 'fa-wrench',
|
||||
Sliders = 'fa-sliders-h',
|
||||
|
||||
// 미디어
|
||||
Image = 'fa-image',
|
||||
Images = 'fa-images',
|
||||
Video = 'fa-video',
|
||||
Camera = 'fa-camera',
|
||||
|
||||
// 소셜
|
||||
Heart = 'fa-heart',
|
||||
Star = 'fa-star',
|
||||
ThumbsUp = 'fa-thumbs-up',
|
||||
ThumbsDown = 'fa-thumbs-down',
|
||||
Share = 'fa-share',
|
||||
|
||||
// 시간
|
||||
Clock = 'fa-clock',
|
||||
Calendar = 'fa-calendar',
|
||||
CalendarAlt = 'fa-calendar-alt',
|
||||
|
||||
// 기타
|
||||
Lock = 'fa-lock',
|
||||
Unlock = 'fa-unlock',
|
||||
Eye = 'fa-eye',
|
||||
EyeSlash = 'fa-eye-slash',
|
||||
Bell = 'fa-bell',
|
||||
Flag = 'fa-flag',
|
||||
Map = 'fa-map',
|
||||
MapMarker = 'fa-map-marker-alt',
|
||||
Globe = 'fa-globe',
|
||||
Link = 'fa-link',
|
||||
Unlink = 'fa-unlink',
|
||||
Sun = 'fa-sun',
|
||||
Moon = 'fa-moon',
|
||||
|
||||
// 차트 및 통계
|
||||
ChartBar = 'fa-chart-bar',
|
||||
ChartLine = 'fa-chart-line',
|
||||
ChartPie = 'fa-chart-pie',
|
||||
ChartArea = 'fa-chart-area',
|
||||
|
||||
// 스피너 (로딩)
|
||||
Spinner = 'fa-spinner',
|
||||
CircleNotch = 'fa-circle-notch',
|
||||
|
||||
// 배지 아이콘
|
||||
CheckBadge = 'fa-badge-check',
|
||||
|
||||
// 알림 관련
|
||||
BellSlash = 'fa-bell-slash',
|
||||
|
||||
// 문서/책 관련
|
||||
BookOpen = 'fa-book-open',
|
||||
|
||||
// 건물/주소 관련
|
||||
Building = 'fa-building',
|
||||
|
||||
// 파일 관련
|
||||
FileCircleCheck = 'fa-file-circle-check',
|
||||
|
||||
// 마법 관련
|
||||
WandMagicSparkles = 'fa-wand-magic-sparkles',
|
||||
|
||||
// 사용자 관련
|
||||
UserXmark = 'fa-user-xmark',
|
||||
|
||||
// 확장 시스템 (모듈/플러그인)
|
||||
Cube = 'fa-cube',
|
||||
Cubes = 'fa-cubes',
|
||||
Plug = 'fa-plug',
|
||||
PuzzlePiece = 'fa-puzzle-piece',
|
||||
|
||||
// 추가 아이콘 (Lucide 호환)
|
||||
Fire = 'fa-fire',
|
||||
CommentDots = 'fa-comment-dots',
|
||||
DollarSign = 'fa-dollar-sign',
|
||||
Bars2 = 'fa-bars',
|
||||
X = 'fa-times',
|
||||
List = 'fa-list',
|
||||
ThLarge = 'fa-th-large',
|
||||
Inbox = 'fa-inbox',
|
||||
Box = 'fa-box',
|
||||
PaperPlane = 'fa-paper-plane',
|
||||
FileText = 'fa-file-alt',
|
||||
Paperclip = 'fa-paperclip',
|
||||
Truck = 'fa-truck',
|
||||
Sync = 'fa-sync',
|
||||
RotateLeft = 'fa-rotate-left',
|
||||
MapPin = 'fa-map-marker-alt',
|
||||
HelpCircle = 'fa-question-circle',
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 아이콘 이름을 IconName enum으로 매핑합니다.
|
||||
* JSON 레이아웃에서 문자열로 아이콘 이름을 지정할 때 사용합니다.
|
||||
*/
|
||||
export const iconNameMap: Record<string, IconName> = {
|
||||
// 사용자 및 계정
|
||||
'user': IconName.User,
|
||||
'users': IconName.Users,
|
||||
'user-circle': IconName.UserCircle,
|
||||
'user-plus': IconName.UserPlus,
|
||||
'user-minus': IconName.UserMinus,
|
||||
'user-shield': IconName.UserShield,
|
||||
|
||||
// 액션 아이콘
|
||||
'search': IconName.Search,
|
||||
'edit': IconName.Edit,
|
||||
'pencil': IconName.Pencil,
|
||||
'trash': IconName.Trash,
|
||||
'save': IconName.Save,
|
||||
'plus': IconName.Plus,
|
||||
'minus': IconName.Minus,
|
||||
'check': IconName.Check,
|
||||
'times': IconName.Times,
|
||||
'download': IconName.Download,
|
||||
'upload': IconName.Upload,
|
||||
'copy': IconName.Copy,
|
||||
'external-link': IconName.ExternalLink,
|
||||
|
||||
// 네비게이션
|
||||
'home': IconName.Home,
|
||||
'chevron-left': IconName.ChevronLeft,
|
||||
'chevron-right': IconName.ChevronRight,
|
||||
'chevron-up': IconName.ChevronUp,
|
||||
'chevron-down': IconName.ChevronDown,
|
||||
'angle-left': IconName.AngleLeft,
|
||||
'angle-right': IconName.AngleRight,
|
||||
'angle-up': IconName.AngleUp,
|
||||
'angle-down': IconName.AngleDown,
|
||||
'arrow-up': IconName.ArrowUp,
|
||||
'arrow-down': IconName.ArrowDown,
|
||||
'arrow-left': IconName.ArrowLeft,
|
||||
'arrow-right': IconName.ArrowRight,
|
||||
'bars': IconName.Bars,
|
||||
'ellipsis-vertical': IconName.EllipsisVertical,
|
||||
'ellipsis-v': IconName.EllipsisVertical,
|
||||
'ellipsis-horizontal': IconName.EllipsisHorizontal,
|
||||
'ellipsis-h': IconName.EllipsisHorizontal,
|
||||
|
||||
// 상태 아이콘
|
||||
'check-circle': IconName.CheckCircle,
|
||||
'times-circle': IconName.TimesCircle,
|
||||
'x-circle': IconName.XCircle,
|
||||
'exclamation-circle': IconName.ExclamationCircle,
|
||||
'exclamation-triangle': IconName.ExclamationTriangle,
|
||||
'info-circle': IconName.InfoCircle,
|
||||
'question-circle': IconName.QuestionCircle,
|
||||
|
||||
// 이커머스
|
||||
'shopping-cart': IconName.ShoppingCart,
|
||||
'shopping-bag': IconName.ShoppingBag,
|
||||
'credit-card': IconName.CreditCard,
|
||||
'tag': IconName.Tag,
|
||||
'tags': IconName.Tags,
|
||||
|
||||
// 파일 및 문서
|
||||
'file': IconName.File,
|
||||
'file-alt': IconName.FileAlt,
|
||||
'folder': IconName.Folder,
|
||||
'folder-open': IconName.FolderOpen,
|
||||
|
||||
// 통신
|
||||
'envelope': IconName.Envelope,
|
||||
'phone': IconName.Phone,
|
||||
'comment': IconName.Comment,
|
||||
'comments': IconName.Comments,
|
||||
|
||||
// 설정
|
||||
'cog': IconName.Cog,
|
||||
'cogs': IconName.Cogs,
|
||||
'settings': IconName.Settings,
|
||||
'wrench': IconName.Wrench,
|
||||
'sliders': IconName.Sliders,
|
||||
|
||||
// 미디어
|
||||
'image': IconName.Image,
|
||||
'images': IconName.Images,
|
||||
'video': IconName.Video,
|
||||
'camera': IconName.Camera,
|
||||
|
||||
// 소셜
|
||||
'heart': IconName.Heart,
|
||||
'star': IconName.Star,
|
||||
'thumbs-up': IconName.ThumbsUp,
|
||||
'thumbs-down': IconName.ThumbsDown,
|
||||
'share': IconName.Share,
|
||||
|
||||
// 시간
|
||||
'clock': IconName.Clock,
|
||||
'calendar': IconName.Calendar,
|
||||
'calendar-alt': IconName.CalendarAlt,
|
||||
|
||||
// 기타
|
||||
'lock': IconName.Lock,
|
||||
'unlock': IconName.Unlock,
|
||||
'eye': IconName.Eye,
|
||||
'eye-slash': IconName.EyeSlash,
|
||||
'bell': IconName.Bell,
|
||||
'flag': IconName.Flag,
|
||||
'map': IconName.Map,
|
||||
'map-marker': IconName.MapMarker,
|
||||
'globe': IconName.Globe,
|
||||
'link': IconName.Link,
|
||||
'unlink': IconName.Unlink,
|
||||
'sun': IconName.Sun,
|
||||
'moon': IconName.Moon,
|
||||
|
||||
// 차트 및 통계
|
||||
'chart-bar': IconName.ChartBar,
|
||||
'chart-line': IconName.ChartLine,
|
||||
'chart-pie': IconName.ChartPie,
|
||||
'chart-area': IconName.ChartArea,
|
||||
|
||||
// 스피너 (로딩)
|
||||
'spinner': IconName.Spinner,
|
||||
'circle-notch': IconName.CircleNotch,
|
||||
|
||||
// 배지 아이콘
|
||||
'check-badge': IconName.CheckBadge,
|
||||
'badge-check': IconName.CheckBadge,
|
||||
|
||||
// 확장 시스템 (모듈/플러그인)
|
||||
'cube': IconName.Cube,
|
||||
'cubes': IconName.Cubes,
|
||||
'plug': IconName.Plug,
|
||||
'puzzle-piece': IconName.PuzzlePiece,
|
||||
|
||||
// Lucide 호환 아이콘 매핑
|
||||
'flame': IconName.Fire,
|
||||
'fire': IconName.Fire,
|
||||
'message-square': IconName.CommentDots,
|
||||
'dollar-sign': IconName.DollarSign,
|
||||
'menu': IconName.Bars,
|
||||
'x': IconName.X,
|
||||
'list': IconName.List,
|
||||
'layout-grid': IconName.ThLarge,
|
||||
'th-large': IconName.ThLarge,
|
||||
'grid': IconName.ThLarge,
|
||||
'inbox': IconName.Inbox,
|
||||
'package': IconName.Box,
|
||||
'box': IconName.Box,
|
||||
'send': IconName.PaperPlane,
|
||||
'paper-plane': IconName.PaperPlane,
|
||||
'file-text': IconName.FileText,
|
||||
'paperclip': IconName.Paperclip,
|
||||
'truck': IconName.Truck,
|
||||
'refresh-cw': IconName.Sync,
|
||||
'sync': IconName.Sync,
|
||||
'undo-2': IconName.RotateLeft,
|
||||
'rotate-left': IconName.RotateLeft,
|
||||
'map-pin': IconName.MapPin,
|
||||
'help-circle': IconName.HelpCircle,
|
||||
|
||||
// 추가 Lucide 호환 아이콘 매핑
|
||||
'alert-triangle': IconName.ExclamationTriangle,
|
||||
'bell-off': IconName.BellSlash,
|
||||
'book-open': IconName.BookOpen,
|
||||
'building': IconName.Building,
|
||||
'edit-2': IconName.Edit,
|
||||
'edit-3': IconName.Edit,
|
||||
'file-check': IconName.FileCircleCheck,
|
||||
'mail': IconName.Envelope,
|
||||
'sparkles': IconName.WandMagicSparkles,
|
||||
'trash-2': IconName.Trash,
|
||||
'user-x': IconName.UserXmark,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface ImgProps extends React.ImgHTMLAttributes<HTMLImageElement> {}
|
||||
|
||||
/**
|
||||
* 기본 이미지 컴포넌트
|
||||
*/
|
||||
export const Img: React.FC<ImgProps> = ({
|
||||
className = '',
|
||||
alt = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useRef, useCallback, useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 입력 필드 컴포넌트
|
||||
*
|
||||
* IME(한글 등) 조합 중에는 외부 onChange 이벤트를 발생시키지 않습니다.
|
||||
* 내부 로컬 상태를 사용하여 입력을 처리하고, 조합이 완료되면 외부에 알립니다.
|
||||
*
|
||||
* forwardRef를 사용하여 외부에서 ref로 input 요소에 접근할 수 있습니다.
|
||||
* 예: indeterminate 상태 설정 시 ref.current.indeterminate = true
|
||||
*/
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
label,
|
||||
error,
|
||||
className = '',
|
||||
onChange,
|
||||
onKeyPress,
|
||||
value,
|
||||
defaultValue,
|
||||
type = 'text',
|
||||
checked,
|
||||
defaultChecked,
|
||||
...props
|
||||
}, ref) => {
|
||||
// radio/checkbox 타입인지 확인
|
||||
const isCheckableType = type === 'radio' || type === 'checkbox';
|
||||
|
||||
// IME 조합 중인지 추적 (text input에만 필요)
|
||||
const isComposingRef = useRef(false);
|
||||
// input 요소 참조
|
||||
const internalRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 외부 ref와 내부 ref를 연결
|
||||
useImperativeHandle(ref, () => internalRef.current as HTMLInputElement);
|
||||
|
||||
// 내부 로컬 값 (IME 조합 중에도 화면에 표시됨) - text input에만 사용
|
||||
const [localValue, setLocalValue] = useState<string>(
|
||||
(value as string) ?? (defaultValue as string) ?? ''
|
||||
);
|
||||
|
||||
// 외부 value prop이 변경되면 로컬 값도 동기화 (text input에만 적용)
|
||||
useEffect(() => {
|
||||
if (!isCheckableType && !isComposingRef.current && value !== undefined) {
|
||||
setLocalValue(value as string);
|
||||
}
|
||||
}, [value, isCheckableType]);
|
||||
|
||||
const handleCompositionStart = useCallback(() => {
|
||||
isComposingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleCompositionEnd = useCallback(
|
||||
(e: React.CompositionEvent<HTMLInputElement>) => {
|
||||
isComposingRef.current = false;
|
||||
// 조합 완료 후 onChange 이벤트 발생
|
||||
if (onChange) {
|
||||
// 현재 input의 실제 값을 사용
|
||||
const currentValue = (e.target as HTMLInputElement).value;
|
||||
setLocalValue(currentValue);
|
||||
|
||||
// 새로운 ChangeEvent 생성
|
||||
const changeEvent = new Event('change', { bubbles: true }) as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||
Object.defineProperty(changeEvent, 'target', {
|
||||
writable: false,
|
||||
value: { value: currentValue },
|
||||
});
|
||||
Object.defineProperty(changeEvent, 'currentTarget', {
|
||||
writable: false,
|
||||
value: { value: currentValue },
|
||||
});
|
||||
|
||||
onChange(changeEvent);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
// 항상 로컬 값은 업데이트 (화면에 입력이 보이도록)
|
||||
setLocalValue(newValue);
|
||||
|
||||
// IME 조합 중이면 외부 onChange를 호출하지 않음
|
||||
if (isComposingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// IME 조합 중이면 keypress 무시 (Enter 등)
|
||||
if (isComposingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (onKeyPress) {
|
||||
onKeyPress(e);
|
||||
}
|
||||
},
|
||||
[onKeyPress]
|
||||
);
|
||||
|
||||
// HTML 표준 속성이 아닌 것들을 필터링 (템플릿 엔진 내부 속성)
|
||||
const { loadingactions, formdata, ...validProps } = props as any;
|
||||
|
||||
// radio/checkbox: checked 속성 사용
|
||||
// text input: value 속성 사용 (IME 처리 포함)
|
||||
if (isCheckableType) {
|
||||
return (
|
||||
<input
|
||||
ref={internalRef}
|
||||
type={type}
|
||||
className={className}
|
||||
onChange={onChange}
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
value={value}
|
||||
{...validProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// text input: controlled/uncontrolled 구분
|
||||
const inputProps = value !== undefined
|
||||
? { value: localValue }
|
||||
: { defaultValue };
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={internalRef}
|
||||
type={type}
|
||||
className={className}
|
||||
onChange={handleChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
{...inputProps}
|
||||
{...validProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// displayName 설정 (React DevTools에서 컴포넌트 이름 표시)
|
||||
Input.displayName = 'Input';
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
/**
|
||||
* 기본 레이블 컴포넌트
|
||||
*/
|
||||
export const Label: React.FC<LabelProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface LiProps extends React.LiHTMLAttributes<HTMLLIElement> {}
|
||||
|
||||
/**
|
||||
* 기본 li(목록 항목) 컴포넌트
|
||||
*/
|
||||
export const Li: React.FC<LiProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<li
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
export interface NavProps extends React.HTMLAttributes<HTMLElement> {}
|
||||
|
||||
/**
|
||||
* 기본 nav 컴포넌트
|
||||
*/
|
||||
export const Nav = forwardRef<HTMLElement, NavProps>(
|
||||
({ children, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<nav
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Nav.displayName = 'Nav';
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface OlProps extends React.HTMLAttributes<HTMLOListElement> {}
|
||||
|
||||
/**
|
||||
* 기본 ol(순서있는 목록) 컴포넌트
|
||||
*/
|
||||
export const Ol: React.FC<OlProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<ol
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface OptgroupProps extends React.OptgroupHTMLAttributes<HTMLOptGroupElement> {}
|
||||
|
||||
/**
|
||||
* 기본 optgroup 컴포넌트 (Select 내부에서 옵션 그룹화)
|
||||
*/
|
||||
export const Optgroup: React.FC<OptgroupProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<optgroup
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</optgroup>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface OptionProps extends React.OptionHTMLAttributes<HTMLOptionElement> {}
|
||||
|
||||
/**
|
||||
* 기본 option 컴포넌트 (Select 내부에서 사용)
|
||||
*/
|
||||
export const Option: React.FC<OptionProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<option
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</option>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface PProps extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||
|
||||
/**
|
||||
* 기본 p(단락) 컴포넌트
|
||||
*/
|
||||
export const P: React.FC<PProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<p
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,418 @@
|
||||
import React, { useState, useCallback, forwardRef, useImperativeHandle, useRef, useEffect, useMemo } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
// G7Core.t() 번역 함수 참조
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
(window as unknown as { G7Core?: { t?: (key: string, params?: Record<string, string | number>) => string } })
|
||||
.G7Core?.t?.(key, params) ?? key;
|
||||
|
||||
/**
|
||||
* 비밀번호 검증 규칙 정의
|
||||
*/
|
||||
export interface PasswordRule {
|
||||
/** 규칙 식별자 */
|
||||
key: string;
|
||||
/** 규칙 설명 (다국어 키) */
|
||||
labelKey: string;
|
||||
/** 검증 함수 (param은 규칙별 설정값) */
|
||||
validate: (value: string, param?: number) => boolean;
|
||||
/** 기본 파라미터 값 */
|
||||
defaultParam?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 모든 비밀번호 검증 규칙
|
||||
*
|
||||
* JSON 레이아웃에서 enableRules prop으로 키를 지정하여 선택적으로 활성화
|
||||
* 파라미터가 있는 규칙은 "규칙명:값" 형식으로 지정 (예: "minLength:12")
|
||||
*/
|
||||
export const availablePasswordRules: Record<string, PasswordRule> = {
|
||||
minLength: {
|
||||
key: 'minLength',
|
||||
labelKey: 'auth.password_input.rules.minLength',
|
||||
defaultParam: 6,
|
||||
validate: (value: string, param = 6) => value.length >= param,
|
||||
},
|
||||
maxLength: {
|
||||
key: 'maxLength',
|
||||
labelKey: 'auth.password_input.rules.maxLength',
|
||||
defaultParam: 20,
|
||||
validate: (value: string, param = 20) => value.length <= param,
|
||||
},
|
||||
hasUppercase: {
|
||||
key: 'hasUppercase',
|
||||
labelKey: 'auth.password_input.rules.hasUppercase',
|
||||
validate: (value: string) => /[A-Z]/.test(value),
|
||||
},
|
||||
hasLowercase: {
|
||||
key: 'hasLowercase',
|
||||
labelKey: 'auth.password_input.rules.hasLowercase',
|
||||
validate: (value: string) => /[a-z]/.test(value),
|
||||
},
|
||||
hasNumber: {
|
||||
key: 'hasNumber',
|
||||
labelKey: 'auth.password_input.rules.hasNumber',
|
||||
validate: (value: string) => /[0-9]/.test(value),
|
||||
},
|
||||
hasSpecial: {
|
||||
key: 'hasSpecial',
|
||||
labelKey: 'auth.password_input.rules.hasSpecial',
|
||||
validate: (value: string) => /[!@#$%^&*(),.?":{}|<>]/.test(value),
|
||||
},
|
||||
noSpaces: {
|
||||
key: 'noSpaces',
|
||||
labelKey: 'auth.password_input.rules.noSpaces',
|
||||
validate: (value: string) => !/\s/.test(value),
|
||||
},
|
||||
minTypes: {
|
||||
key: 'minTypes',
|
||||
labelKey: 'auth.password_input.rules.minTypes',
|
||||
defaultParam: 3,
|
||||
validate: (value: string, param = 3) => {
|
||||
let types = 0;
|
||||
if (/[A-Z]/.test(value)) types++;
|
||||
if (/[a-z]/.test(value)) types++;
|
||||
if (/[0-9]/.test(value)) types++;
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(value)) types++;
|
||||
return types >= param;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 기본 비밀번호 검증 규칙
|
||||
*
|
||||
* 백엔드 검증 규칙(RegisterRequest.php)과 동일:
|
||||
* - password: required|string|min:6|confirmed
|
||||
*/
|
||||
export const defaultPasswordRules: PasswordRule[] = [
|
||||
availablePasswordRules.minLength,
|
||||
];
|
||||
|
||||
/**
|
||||
* 파싱된 규칙 (규칙 + 파라미터)
|
||||
*/
|
||||
interface ParsedRule {
|
||||
rule: PasswordRule;
|
||||
param?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* enableRules 문자열을 파싱하여 규칙과 파라미터를 추출
|
||||
* 예: "minLength:12" → { rule: minLengthRule, param: 12 }
|
||||
* 예: "hasUppercase" → { rule: hasUppercaseRule, param: undefined }
|
||||
*
|
||||
* @param ruleString 규칙 문자열 (예: "minLength:12" 또는 "hasUppercase")
|
||||
* @returns 파싱된 규칙 또는 null (규칙이 존재하지 않는 경우)
|
||||
*/
|
||||
function parseRuleString(ruleString: string): ParsedRule | null {
|
||||
const [key, paramStr] = ruleString.split(':');
|
||||
const rule = availablePasswordRules[key];
|
||||
if (!rule) return null;
|
||||
|
||||
const param = paramStr ? parseInt(paramStr, 10) : rule.defaultParam;
|
||||
return { rule, param: isNaN(param as number) ? rule.defaultParam : param };
|
||||
}
|
||||
|
||||
/**
|
||||
* PasswordInput에서 사용하지 않는 HTML 속성 목록
|
||||
* (그누보드7 템플릿 엔진에서 전달되는 커스텀 속성)
|
||||
*/
|
||||
interface NonStandardProps {
|
||||
loadingactions?: unknown;
|
||||
formdata?: unknown;
|
||||
}
|
||||
|
||||
export interface PasswordInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
/** 레이블 텍스트 */
|
||||
label?: string;
|
||||
/** 에러 메시지 */
|
||||
error?: string;
|
||||
/** 비밀번호 보기/숨기기 버튼 표시 */
|
||||
showToggle?: boolean;
|
||||
/** 비밀번호 조건 검증 표시 */
|
||||
showValidation?: boolean;
|
||||
/** 비밀번호 확인 모드 (확인 필드로 사용) */
|
||||
isConfirmField?: boolean;
|
||||
/** 비밀번호 확인 대상 값 (isConfirmField가 true일 때 필요) */
|
||||
confirmTarget?: string;
|
||||
/** 비밀번호 확인 일치 여부 콜백 */
|
||||
onMatchChange?: (isMatch: boolean) => void;
|
||||
/** 비밀번호 유효성 변경 콜백 */
|
||||
onValidityChange?: (isValid: boolean, failedRules: string[]) => void;
|
||||
/** 커스텀 비밀번호 규칙 (기본 규칙 대체, 코드에서만 사용) */
|
||||
rules?: PasswordRule[];
|
||||
/**
|
||||
* 활성화할 규칙 키 목록 (JSON 레이아웃에서 사용)
|
||||
* 사용 가능: minLength, maxLength, hasUppercase, hasLowercase, hasNumber, hasSpecial, noSpaces, minTypes
|
||||
* 파라미터 지정: "규칙명:값" 형식 (예: "minLength:12", "minTypes:3")
|
||||
* 예: ["minLength:12", "hasUppercase", "hasSpecial"]
|
||||
*/
|
||||
enableRules?: string[];
|
||||
/** 표시할 규칙 키 목록 (지정하지 않으면 전체 표시) */
|
||||
showRules?: string[];
|
||||
/** 입력 필드 wrapper 클래스 */
|
||||
wrapperClassName?: string;
|
||||
/** 검증 결과 표시 영역 클래스 */
|
||||
validationClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 입력 컴포넌트
|
||||
*
|
||||
* 비밀번호 보기/숨기기 토글, 실시간 조건 검증, 비밀번호 확인 기능을 제공합니다.
|
||||
*
|
||||
* @example
|
||||
* // 기본 사용 (보기/숨기기만)
|
||||
* <PasswordInput name="password" showToggle />
|
||||
*
|
||||
* @example
|
||||
* // 조건 검증 표시
|
||||
* <PasswordInput
|
||||
* name="password"
|
||||
* showToggle
|
||||
* showValidation
|
||||
* onValidityChange={(isValid, failed) => console.log(isValid, failed)}
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // 비밀번호 확인 필드
|
||||
* <PasswordInput
|
||||
* name="password_confirmation"
|
||||
* isConfirmField
|
||||
* confirmTarget={password}
|
||||
* onMatchChange={(isMatch) => console.log(isMatch)}
|
||||
* />
|
||||
*/
|
||||
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
error,
|
||||
showToggle = true,
|
||||
showValidation = false,
|
||||
isConfirmField = false,
|
||||
confirmTarget,
|
||||
onMatchChange,
|
||||
onValidityChange,
|
||||
rules,
|
||||
enableRules,
|
||||
showRules,
|
||||
wrapperClassName = '',
|
||||
validationClassName = '',
|
||||
className = '',
|
||||
onChange,
|
||||
value,
|
||||
defaultValue,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const internalRef = useRef<HTMLInputElement>(null);
|
||||
useImperativeHandle(ref, () => internalRef.current as HTMLInputElement);
|
||||
|
||||
// 비밀번호 보기/숨기기 상태
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// CapsLock 활성화 상태
|
||||
const [capsLockOn, setCapsLockOn] = useState(false);
|
||||
|
||||
// 내부 값 상태 (controlled/uncontrolled 지원)
|
||||
const [internalValue, setInternalValue] = useState<string>(
|
||||
(value as string) ?? (defaultValue as string) ?? ''
|
||||
);
|
||||
|
||||
// 외부 value가 변경되면 동기화
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
setInternalValue(value as string);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 사용할 규칙 결정: rules > enableRules > defaultPasswordRules
|
||||
const parsedRules = useMemo((): ParsedRule[] => {
|
||||
// 1. rules prop이 있으면 그대로 사용 (코드에서 직접 전달, 기본 파라미터 적용)
|
||||
if (rules && rules.length > 0) {
|
||||
return rules.map((rule) => ({ rule, param: rule.defaultParam }));
|
||||
}
|
||||
|
||||
// 2. enableRules가 있으면 파싱하여 규칙과 파라미터 추출
|
||||
if (enableRules && enableRules.length > 0) {
|
||||
return enableRules
|
||||
.map((ruleStr) => parseRuleString(ruleStr))
|
||||
.filter((parsed): parsed is ParsedRule => parsed !== null);
|
||||
}
|
||||
|
||||
// 3. 기본 규칙 사용
|
||||
return defaultPasswordRules.map((rule) => ({ rule, param: rule.defaultParam }));
|
||||
}, [rules, enableRules]);
|
||||
|
||||
// 표시할 규칙 필터링
|
||||
const displayRules = useMemo(() => {
|
||||
if (!showRules || showRules.length === 0) return parsedRules;
|
||||
return parsedRules.filter((parsed) => showRules.includes(parsed.rule.key));
|
||||
}, [parsedRules, showRules]);
|
||||
|
||||
// 비밀번호 규칙 검증 결과
|
||||
const validationResults = useMemo(() => {
|
||||
if (!showValidation || isConfirmField) return {};
|
||||
return displayRules.reduce((acc, { rule, param }) => {
|
||||
acc[rule.key] = rule.validate(internalValue, param);
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
}, [internalValue, displayRules, showValidation, isConfirmField]);
|
||||
|
||||
// 비밀번호 확인 일치 여부
|
||||
const isMatch = useMemo(() => {
|
||||
if (!isConfirmField || confirmTarget === undefined) return true;
|
||||
return internalValue === confirmTarget && internalValue.length > 0;
|
||||
}, [isConfirmField, internalValue, confirmTarget]);
|
||||
|
||||
// 전체 유효성 (모든 규칙 통과)
|
||||
const isAllValid = useMemo(() => {
|
||||
if (!showValidation) return true;
|
||||
return Object.values(validationResults).every((v) => v);
|
||||
}, [validationResults, showValidation]);
|
||||
|
||||
// 유효성 변경 콜백
|
||||
useEffect(() => {
|
||||
if (onValidityChange && showValidation && !isConfirmField) {
|
||||
const failedRules = Object.entries(validationResults)
|
||||
.filter(([, passed]) => !passed)
|
||||
.map(([key]) => key);
|
||||
onValidityChange(isAllValid, failedRules);
|
||||
}
|
||||
}, [isAllValid, validationResults, onValidityChange, showValidation, isConfirmField]);
|
||||
|
||||
// 비밀번호 확인 일치 콜백
|
||||
useEffect(() => {
|
||||
if (onMatchChange && isConfirmField) {
|
||||
onMatchChange(isMatch);
|
||||
}
|
||||
}, [isMatch, onMatchChange, isConfirmField]);
|
||||
|
||||
// 비밀번호 보기/숨기기 토글
|
||||
const handleToggle = useCallback(() => {
|
||||
setShowPassword((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// 입력값 변경 처리
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInternalValue(newValue);
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// CapsLock 상태 감지
|
||||
const handleKeyEvent = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
setCapsLockOn(e.getModifierState('CapsLock'));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// HTML 표준 속성이 아닌 것들을 필터링 (타입 안전성 개선)
|
||||
const { loadingactions, formdata, ...validProps } = props as typeof props & NonStandardProps;
|
||||
|
||||
return (
|
||||
<div className={`relative ${wrapperClassName}`}>
|
||||
{/* 입력 필드 wrapper */}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={internalRef}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={`${className} ${showToggle ? 'pr-10' : ''}`}
|
||||
value={value !== undefined ? (value as string) : undefined}
|
||||
defaultValue={value === undefined ? defaultValue : undefined}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyEvent}
|
||||
onKeyUp={handleKeyEvent}
|
||||
disabled={disabled}
|
||||
{...validProps}
|
||||
/>
|
||||
|
||||
{/* 비밀번호 보기/숨기기 버튼 */}
|
||||
{showToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
disabled={disabled}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed dark:disabled:opacity-50"
|
||||
aria-label={showPassword ? t('auth.password_input.hide') : t('auth.password_input.show')}
|
||||
>
|
||||
<Icon
|
||||
name={showPassword ? 'eye-slash' : 'eye'}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CapsLock 경고 */}
|
||||
{capsLockOn && !showPassword && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-sm text-yellow-600 dark:text-yellow-400">
|
||||
<Icon name="triangle-exclamation" className="w-4 h-4" />
|
||||
<span>{t('auth.password_input.caps_lock_on')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 비밀번호 확인 일치/불일치 표시 */}
|
||||
{isConfirmField && confirmTarget !== undefined && internalValue.length > 0 && (
|
||||
<div
|
||||
className={`mt-2 flex items-center gap-1.5 text-sm ${
|
||||
isMatch
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
name={isMatch ? 'check-circle' : 'xmark-circle'}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{isMatch ? t('auth.password_input.match') : t('auth.password_input.mismatch')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 비밀번호 조건 검증 표시 */}
|
||||
{showValidation && !isConfirmField && internalValue.length > 0 && (
|
||||
<div className={`mt-2 space-y-1 ${validationClassName}`}>
|
||||
{displayRules.map(({ rule, param }) => {
|
||||
const passed = validationResults[rule.key];
|
||||
// 파라미터가 있는 규칙은 라벨에 파라미터 값 전달
|
||||
const labelParams = param !== undefined ? { count: param } : undefined;
|
||||
return (
|
||||
<div
|
||||
key={rule.key}
|
||||
className={`flex items-center gap-1.5 text-sm ${
|
||||
passed
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
name={passed ? 'check-circle' : 'circle'}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{t(rule.labelKey, labelParams)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PasswordInput.displayName = 'PasswordInput';
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface SectionProps extends React.HTMLAttributes<HTMLElement> {}
|
||||
|
||||
/**
|
||||
* 기본 section 컴포넌트
|
||||
*/
|
||||
export const Section: React.FC<SectionProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<section
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { Div } from './Div';
|
||||
import { Button } from './Button';
|
||||
import { Span } from './Span';
|
||||
import { Svg } from './Svg';
|
||||
|
||||
// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)
|
||||
const logger = ((window as any).G7Core?.createLogger?.('Comp:Select')) ?? {
|
||||
log: (...args: unknown[]) => console.log('[Comp:Select]', ...args),
|
||||
warn: (...args: unknown[]) => console.warn('[Comp:Select]', ...args),
|
||||
error: (...args: unknown[]) => console.error('[Comp:Select]', ...args),
|
||||
};
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options?: SelectOption[] | string[];
|
||||
onChange?: (e: React.ChangeEvent<HTMLSelectElement> | { target: { value: string | number } }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로케일 코드를 사람이 읽을 수 있는 이름으로 변환
|
||||
*/
|
||||
function getLocaleName(locale: string): string {
|
||||
const localeNames: Record<string, string> = {
|
||||
ko: '한국어',
|
||||
en: 'English',
|
||||
ja: '日本語',
|
||||
zh: '中文',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
};
|
||||
|
||||
return localeNames[locale] || locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 Select 컴포넌트
|
||||
*
|
||||
* options prop을 사용하여 드롭다운 메뉴를 생성합니다.
|
||||
* 둥근 모서리, 그림자, 체크마크가 있는 커스텀 스타일을 지원합니다.
|
||||
*/
|
||||
export const Select: React.FC<SelectProps> = ({
|
||||
children,
|
||||
label,
|
||||
error,
|
||||
options,
|
||||
className = '',
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// options를 SelectOption[] 형식으로 정규화
|
||||
const normalizedOptions = useMemo((): SelectOption[] | null => {
|
||||
if (!options) return null;
|
||||
|
||||
// 배열이 아닌 경우 (바인딩 에러 또는 잘못된 prop)
|
||||
if (!Array.isArray(options)) {
|
||||
logger.warn('options prop is not an array:', options);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 빈 배열인 경우
|
||||
if (options.length === 0) return [];
|
||||
|
||||
// string[] 배열인 경우
|
||||
if (typeof options[0] === 'string') {
|
||||
return (options as string[]).map((locale): SelectOption => ({
|
||||
value: locale,
|
||||
label: getLocaleName(locale),
|
||||
}));
|
||||
}
|
||||
|
||||
// SelectOption[] 배열인 경우 (기존 동작)
|
||||
return options as SelectOption[];
|
||||
}, [options]);
|
||||
|
||||
// 현재 선택된 옵션의 라벨 가져오기
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (!normalizedOptions) return '';
|
||||
const selected = normalizedOptions.find(opt => String(opt.value) === String(value));
|
||||
return selected?.label || '';
|
||||
}, [normalizedOptions, value]);
|
||||
|
||||
// 외부 클릭 시 드롭다운 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// ESC 키로 드롭다운 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!disabled) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (optionValue: string | number) => {
|
||||
if (onChange) {
|
||||
// ActionDispatcher가 이벤트를 인식할 수 있도록 preventDefault 메서드 포함
|
||||
const syntheticEvent = {
|
||||
target: { value: optionValue },
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
type: 'change',
|
||||
};
|
||||
onChange(syntheticEvent as any);
|
||||
}
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
// options가 없으면 기본 select 사용 (children 지원)
|
||||
if (!normalizedOptions) {
|
||||
return (
|
||||
<select
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={onChange as React.ChangeEventHandler<HTMLSelectElement>}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 스타일 (className이 없을 때 적용)
|
||||
const hasCustomStyle = className && className.includes('bg-');
|
||||
// 커스텀 스타일에 텍스트 색상이 없으면 기본 텍스트 색상 보충
|
||||
const hasTextColor = className && /text-(gray|red|blue|green|yellow|white|black|indigo|purple|pink|orange|amber|emerald|teal|cyan|slate)-\d+|text-white|text-black/.test(className);
|
||||
const baseButtonClass = hasCustomStyle
|
||||
? `${className}${hasTextColor ? '' : ' text-gray-700 dark:text-gray-200'}`
|
||||
: `w-full px-4 py-2.5 bg-gray-100 dark:bg-gray-700 border-0 rounded-xl text-gray-700 dark:text-gray-200 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none ${className}`;
|
||||
|
||||
return (
|
||||
<Div ref={containerRef} className="relative">
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
disabled={disabled}
|
||||
className={`${baseButtonClass} flex items-center justify-between gap-2 text-left cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Span className="truncate">{selectedLabel || '\u00A0'}</Span>
|
||||
<Svg
|
||||
className={`w-4 h-4 flex-shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</Svg>
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<Div
|
||||
className="absolute z-50 w-full mt-2 bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 overflow-hidden"
|
||||
role="listbox"
|
||||
>
|
||||
<Div className="py-2 max-h-60 overflow-auto">
|
||||
{normalizedOptions.map((option) => {
|
||||
const isSelected = String(option.value) === String(value);
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => !option.disabled && handleSelect(option.value)}
|
||||
disabled={option.disabled}
|
||||
className={`w-full px-4 py-3 text-left flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
|
||||
isSelected ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-700 dark:text-gray-200'
|
||||
} ${option.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<Span>{option.label}</Span>
|
||||
{isSelected && (
|
||||
<Svg
|
||||
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</Svg>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Div>
|
||||
</Div>
|
||||
)}
|
||||
</Div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface SpanProps extends React.HTMLAttributes<HTMLSpanElement> {}
|
||||
|
||||
/**
|
||||
* 기본 span 컴포넌트
|
||||
*/
|
||||
export const Span: React.FC<SpanProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface SvgProps extends React.SVGAttributes<SVGSVGElement> {}
|
||||
|
||||
/**
|
||||
* 기본 svg 컴포넌트
|
||||
*/
|
||||
export const Svg: React.FC<SvgProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface TableProps extends React.TableHTMLAttributes<HTMLTableElement> {}
|
||||
|
||||
/**
|
||||
* 기본 table 컴포넌트
|
||||
*/
|
||||
export const Table: React.FC<TableProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<table
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface TbodyProps extends React.HTMLAttributes<HTMLTableSectionElement> {}
|
||||
|
||||
/**
|
||||
* 기본 tbody 컴포넌트
|
||||
*/
|
||||
export const Tbody: React.FC<TbodyProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<tbody
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface TdProps extends React.TdHTMLAttributes<HTMLTableCellElement> {}
|
||||
|
||||
/**
|
||||
* 기본 td(테이블 데이터 셀) 컴포넌트
|
||||
*/
|
||||
export const Td: React.FC<TdProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<td
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useRef, useCallback, useState, useEffect } from 'react';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 텍스트 영역 컴포넌트
|
||||
*
|
||||
* IME(한글 등) 조합 중에는 외부 onChange 이벤트를 발생시키지 않습니다.
|
||||
* 내부 로컬 상태를 사용하여 입력을 처리하고, 조합이 완료되면 외부에 알립니다.
|
||||
*/
|
||||
export const Textarea: React.FC<TextareaProps> = ({
|
||||
label,
|
||||
error,
|
||||
className = '',
|
||||
onChange,
|
||||
value,
|
||||
defaultValue,
|
||||
...props
|
||||
}) => {
|
||||
// IME 조합 중인지 추적
|
||||
const isComposingRef = useRef(false);
|
||||
// textarea 요소 참조
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 내부 로컬 값 (IME 조합 중에도 화면에 표시됨)
|
||||
const [localValue, setLocalValue] = useState<string>(
|
||||
(value as string) ?? (defaultValue as string) ?? ''
|
||||
);
|
||||
|
||||
// 외부 value prop이 변경되면 로컬 값도 동기화
|
||||
useEffect(() => {
|
||||
if (!isComposingRef.current && value !== undefined) {
|
||||
setLocalValue(value as string);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleCompositionStart = useCallback(() => {
|
||||
isComposingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleCompositionEnd = useCallback(
|
||||
(e: React.CompositionEvent<HTMLTextAreaElement>) => {
|
||||
isComposingRef.current = false;
|
||||
// 조합 완료 후 onChange 이벤트 발생
|
||||
if (onChange) {
|
||||
// 현재 textarea의 실제 값을 사용
|
||||
const currentValue = (e.target as HTMLTextAreaElement).value;
|
||||
setLocalValue(currentValue);
|
||||
|
||||
// 새로운 ChangeEvent 생성
|
||||
const changeEvent = new Event('change', { bubbles: true }) as unknown as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
Object.defineProperty(changeEvent, 'target', {
|
||||
writable: false,
|
||||
value: { value: currentValue },
|
||||
});
|
||||
Object.defineProperty(changeEvent, 'currentTarget', {
|
||||
writable: false,
|
||||
value: { value: currentValue },
|
||||
});
|
||||
|
||||
onChange(changeEvent);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
// 항상 로컬 값은 업데이트 (화면에 입력이 보이도록)
|
||||
setLocalValue(newValue);
|
||||
|
||||
// IME 조합 중이면 외부 onChange를 호출하지 않음
|
||||
if (isComposingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// HTML 표준 속성이 아닌 것들을 필터링 (템플릿 엔진 내부 속성)
|
||||
const { loadingactions, formdata, ...validProps } = props as any;
|
||||
|
||||
// controlled/uncontrolled 구분
|
||||
const textareaValueProps = value !== undefined
|
||||
? { value: localValue }
|
||||
: { defaultValue };
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={className}
|
||||
onChange={handleChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
{...textareaValueProps}
|
||||
{...validProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface ThProps extends React.ThHTMLAttributes<HTMLTableCellElement> {}
|
||||
|
||||
/**
|
||||
* 기본 th(테이블 헤더 셀) 컴포넌트
|
||||
*/
|
||||
export const Th: React.FC<ThProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<th
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface TheadProps extends React.HTMLAttributes<HTMLTableSectionElement> {}
|
||||
|
||||
/**
|
||||
* 기본 thead 컴포넌트
|
||||
*/
|
||||
export const Thead: React.FC<TheadProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<thead
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface TrProps extends React.HTMLAttributes<HTMLTableRowElement> {}
|
||||
|
||||
/**
|
||||
* 기본 tr(테이블 행) 컴포넌트
|
||||
*/
|
||||
export const Tr: React.FC<TrProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<tr
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface UlProps extends React.HTMLAttributes<HTMLUListElement> {}
|
||||
|
||||
/**
|
||||
* 기본 ul(순서없는 목록) 컴포넌트
|
||||
*/
|
||||
export const Ul: React.FC<UlProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<ul
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* PasswordInput 컴포넌트 테스트
|
||||
*
|
||||
* @description 비밀번호 입력 컴포넌트의 동작을 테스트합니다.
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PasswordInput, defaultPasswordRules, availablePasswordRules } from '../PasswordInput';
|
||||
|
||||
// Icon 컴포넌트 Mock
|
||||
vi.mock('../Icon', () => ({
|
||||
Icon: ({ name, className }: { name: string; className?: string }) => (
|
||||
<i data-testid={`icon-${name}`} className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
// 다국어 키 -> 텍스트 매핑 (테스트용)
|
||||
const translations: Record<string, string> = {
|
||||
'auth.password_input.show': '비밀번호 보기',
|
||||
'auth.password_input.hide': '비밀번호 숨기기',
|
||||
'auth.password_input.match': '비밀번호가 일치합니다',
|
||||
'auth.password_input.mismatch': '비밀번호가 일치하지 않습니다',
|
||||
'auth.password_input.caps_lock_on': 'CapsLock이 켜져 있습니다',
|
||||
'auth.password_input.rules.minLength': '최소 8자 이상',
|
||||
'auth.password_input.rules.hasUppercase': '대문자 포함',
|
||||
'auth.password_input.rules.hasLowercase': '소문자 포함',
|
||||
'auth.password_input.rules.hasNumber': '숫자 포함',
|
||||
'auth.password_input.rules.hasSpecial': '특수문자 포함',
|
||||
};
|
||||
|
||||
// G7Core.t() Mock 설정
|
||||
const mockT = vi.fn((key: string) => translations[key] ?? key);
|
||||
|
||||
describe('PasswordInput 컴포넌트', () => {
|
||||
beforeEach(() => {
|
||||
// G7Core Mock 설정
|
||||
(window as unknown as { G7Core: { t: typeof mockT } }).G7Core = {
|
||||
t: mockT,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete (window as unknown as { G7Core?: unknown }).G7Core;
|
||||
});
|
||||
|
||||
describe('렌더링', () => {
|
||||
it('기본 렌더링이 되어야 함', () => {
|
||||
render(<PasswordInput name="password" data-testid="password-input" />);
|
||||
const input = screen.getByTestId('password-input');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('showToggle=true일 때 토글 버튼이 렌더링되어야 함', () => {
|
||||
render(<PasswordInput name="password" showToggle={true} />);
|
||||
const toggleButton = screen.getByRole('button', { name: /비밀번호 보기/i });
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('showToggle=false일 때 토글 버튼이 렌더링되지 않아야 함', () => {
|
||||
render(<PasswordInput name="password" showToggle={false} />);
|
||||
const toggleButton = screen.queryByRole('button');
|
||||
expect(toggleButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('에러 메시지가 표시되어야 함', () => {
|
||||
render(<PasswordInput name="password" error="비밀번호 오류" />);
|
||||
expect(screen.getByText('비밀번호 오류')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('비밀번호 보기/숨기기', () => {
|
||||
it('토글 버튼 클릭 시 비밀번호가 표시되어야 함', () => {
|
||||
render(<PasswordInput name="password" data-testid="password-input" />);
|
||||
const input = screen.getByTestId('password-input');
|
||||
const toggleButton = screen.getByRole('button', { name: /비밀번호 보기/i });
|
||||
|
||||
// 초기 상태: password 타입
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
|
||||
// 토글 클릭
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// 변경 상태: text 타입
|
||||
expect(input).toHaveAttribute('type', 'text');
|
||||
});
|
||||
|
||||
it('토글 버튼 두 번 클릭 시 비밀번호가 다시 숨겨져야 함', () => {
|
||||
render(<PasswordInput name="password" data-testid="password-input" />);
|
||||
const input = screen.getByTestId('password-input');
|
||||
const toggleButton = screen.getByRole('button', { name: /비밀번호 보기/i });
|
||||
|
||||
// 두 번 클릭
|
||||
fireEvent.click(toggleButton);
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('disabled 상태에서 토글 버튼도 비활성화되어야 함', () => {
|
||||
render(<PasswordInput name="password" disabled />);
|
||||
const toggleButton = screen.getByRole('button');
|
||||
expect(toggleButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('비밀번호 검증', () => {
|
||||
it('showValidation=true일 때 검증 규칙이 표시되어야 함', () => {
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password"
|
||||
showValidation={true}
|
||||
enableRules={['minLength', 'hasUppercase', 'hasLowercase', 'hasNumber', 'hasSpecial']}
|
||||
defaultValue="test"
|
||||
data-testid="password-input"
|
||||
/>
|
||||
);
|
||||
|
||||
// 기본 규칙들이 표시되어야 함
|
||||
expect(screen.getByText('최소 8자 이상')).toBeInTheDocument();
|
||||
expect(screen.getByText('대문자 포함')).toBeInTheDocument();
|
||||
expect(screen.getByText('소문자 포함')).toBeInTheDocument();
|
||||
expect(screen.getByText('숫자 포함')).toBeInTheDocument();
|
||||
expect(screen.getByText('특수문자 포함')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('조건 충족 시 체크 아이콘이 표시되어야 함', () => {
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password"
|
||||
showValidation={true}
|
||||
enableRules={['minLength', 'hasLowercase', 'hasNumber']}
|
||||
defaultValue="test1234"
|
||||
data-testid="password-input"
|
||||
/>
|
||||
);
|
||||
|
||||
// 8자 이상, 소문자, 숫자 조건 충족
|
||||
// check-circle 아이콘이 표시되어야 함
|
||||
const checkIcons = screen.getAllByTestId('icon-check-circle');
|
||||
expect(checkIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('showRules로 표시할 규칙을 제한할 수 있어야 함', () => {
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password"
|
||||
showValidation={true}
|
||||
enableRules={['minLength', 'hasUppercase', 'hasNumber', 'hasSpecial']}
|
||||
showRules={['minLength', 'hasNumber']}
|
||||
defaultValue="test"
|
||||
data-testid="password-input"
|
||||
/>
|
||||
);
|
||||
|
||||
// 지정한 규칙만 표시
|
||||
expect(screen.getByText('최소 8자 이상')).toBeInTheDocument();
|
||||
expect(screen.getByText('숫자 포함')).toBeInTheDocument();
|
||||
|
||||
// 지정하지 않은 규칙은 표시되지 않음
|
||||
expect(screen.queryByText('대문자 포함')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('특수문자 포함')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onValidityChange 콜백이 호출되어야 함', () => {
|
||||
const onValidityChange = vi.fn();
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password"
|
||||
showValidation={true}
|
||||
onValidityChange={onValidityChange}
|
||||
defaultValue="Test1234!"
|
||||
data-testid="password-input"
|
||||
/>
|
||||
);
|
||||
|
||||
// 기본 규칙(minLength)만 적용되므로 모든 조건 충족 시 isValid=true
|
||||
expect(onValidityChange).toHaveBeenCalledWith(true, []);
|
||||
});
|
||||
|
||||
it('빈 값일 때 검증 결과가 표시되지 않아야 함', () => {
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password"
|
||||
showValidation={true}
|
||||
data-testid="password-input"
|
||||
/>
|
||||
);
|
||||
|
||||
// 검증 규칙 텍스트가 표시되지 않아야 함
|
||||
expect(screen.queryByText('최소 8자 이상')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('비밀번호 확인', () => {
|
||||
it('isConfirmField=true일 때 일치 여부가 표시되어야 함', () => {
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password_confirmation"
|
||||
isConfirmField={true}
|
||||
confirmTarget="password123"
|
||||
defaultValue="password123"
|
||||
data-testid="confirm-input"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('비밀번호가 일치합니다')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('비밀번호가 일치하지 않을 때 불일치 메시지가 표시되어야 함', () => {
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password_confirmation"
|
||||
isConfirmField={true}
|
||||
confirmTarget="password123"
|
||||
defaultValue="different"
|
||||
data-testid="confirm-input"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('비밀번호가 일치하지 않습니다')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onMatchChange 콜백이 호출되어야 함', () => {
|
||||
const onMatchChange = vi.fn();
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password_confirmation"
|
||||
isConfirmField={true}
|
||||
confirmTarget="password123"
|
||||
onMatchChange={onMatchChange}
|
||||
defaultValue="password123"
|
||||
data-testid="confirm-input"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(onMatchChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('빈 값일 때 일치 여부가 표시되지 않아야 함', () => {
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password_confirmation"
|
||||
isConfirmField={true}
|
||||
confirmTarget="password123"
|
||||
data-testid="confirm-input"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('비밀번호가 일치합니다')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('비밀번호가 일치하지 않습니다')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('입력 처리', () => {
|
||||
it('onChange가 호출되어야 함', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password"
|
||||
onChange={onChange}
|
||||
data-testid="password-input"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('password-input');
|
||||
fireEvent.change(input, { target: { value: 'newpassword' } });
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('controlled 모드에서 value prop이 적용되어야 함', () => {
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password"
|
||||
value="controlled-value"
|
||||
onChange={vi.fn()}
|
||||
data-testid="password-input"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('password-input');
|
||||
expect(input).toHaveValue('controlled-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('availablePasswordRules', () => {
|
||||
it('minLength 규칙이 올바르게 동작해야 함', () => {
|
||||
const rule = availablePasswordRules.minLength;
|
||||
// defaultParam=6 이므로 6자 미만이면 false, 6자 이상이면 true
|
||||
expect(rule.validate('12345')).toBe(false); // 5자 - 미달
|
||||
expect(rule.validate('123456')).toBe(true); // 6자 - 통과 (defaultParam=6)
|
||||
});
|
||||
|
||||
it('hasUppercase 규칙이 올바르게 동작해야 함', () => {
|
||||
const rule = availablePasswordRules.hasUppercase;
|
||||
expect(rule.validate('lowercase')).toBe(false);
|
||||
expect(rule.validate('Uppercase')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasLowercase 규칙이 올바르게 동작해야 함', () => {
|
||||
const rule = availablePasswordRules.hasLowercase;
|
||||
expect(rule.validate('UPPERCASE')).toBe(false);
|
||||
expect(rule.validate('lowerCASE')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasNumber 규칙이 올바르게 동작해야 함', () => {
|
||||
const rule = availablePasswordRules.hasNumber;
|
||||
expect(rule.validate('noNumbers')).toBe(false);
|
||||
expect(rule.validate('has1Number')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasSpecial 규칙이 올바르게 동작해야 함', () => {
|
||||
const rule = availablePasswordRules.hasSpecial;
|
||||
expect(rule.validate('noSpecial')).toBe(false);
|
||||
expect(rule.validate('has@Special')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('다국어 지원', () => {
|
||||
it('G7Core.t()가 호출되어야 함', () => {
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password"
|
||||
showValidation={true}
|
||||
defaultValue="test"
|
||||
data-testid="password-input"
|
||||
/>
|
||||
);
|
||||
|
||||
// t() 함수가 규칙 라벨 번역을 위해 호출되었는지 확인
|
||||
// 참고: showToggle=true(기본값)이면 'auth.password_input.show'가 먼저 호출됨
|
||||
// 규칙 번역 시 count 파라미터도 함께 전달됨 (defaultParam=6)
|
||||
expect(mockT).toHaveBeenCalledWith('auth.password_input.rules.minLength', { count: 6 });
|
||||
});
|
||||
|
||||
it('비밀번호 확인 시 다국어 메시지가 표시되어야 함', () => {
|
||||
render(
|
||||
<PasswordInput
|
||||
name="password_confirmation"
|
||||
isConfirmField={true}
|
||||
confirmTarget="password123"
|
||||
defaultValue="password123"
|
||||
data-testid="confirm-input"
|
||||
/>
|
||||
);
|
||||
|
||||
// 일치 메시지 번역이 호출되었는지 확인 (호출 순서는 showToggle 버튼 aria-label이 먼저)
|
||||
expect(mockT).toHaveBeenCalledWith('auth.password_input.match', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultPasswordRules', () => {
|
||||
it('기본 규칙에 minLength만 포함되어야 함', () => {
|
||||
expect(defaultPasswordRules).toHaveLength(1);
|
||||
expect(defaultPasswordRules[0].key).toBe('minLength');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CapsLock 감지', () => {
|
||||
/**
|
||||
* jsdom에서 fireEvent로 getModifierState를 전달해도 React 이벤트에 반영되지 않음
|
||||
* KeyboardEvent를 직접 생성하고 getModifierState를 프로토타입에 추가해야 함
|
||||
* 또한 dispatchEvent는 React 상태 업데이트를 일으키므로 act()로 래핑 필요
|
||||
*/
|
||||
const createKeyboardEvent = (type: 'keydown' | 'keyup', capsLockOn: boolean) => {
|
||||
const event = new KeyboardEvent(type, { bubbles: true, key: 'A' });
|
||||
Object.defineProperty(event, 'getModifierState', {
|
||||
value: (key: string) => key === 'CapsLock' && capsLockOn,
|
||||
});
|
||||
return event;
|
||||
};
|
||||
|
||||
it('CapsLock이 켜져 있을 때 경고 메시지가 표시되어야 함', () => {
|
||||
render(<PasswordInput name="password" data-testid="password-input" />);
|
||||
const input = screen.getByTestId('password-input');
|
||||
|
||||
// CapsLock이 켜진 상태로 keyDown 이벤트 발생
|
||||
act(() => {
|
||||
input.dispatchEvent(createKeyboardEvent('keydown', true));
|
||||
});
|
||||
|
||||
expect(screen.getByText('CapsLock이 켜져 있습니다')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('CapsLock이 꺼지면 경고 메시지가 사라져야 함', () => {
|
||||
render(<PasswordInput name="password" data-testid="password-input" />);
|
||||
const input = screen.getByTestId('password-input');
|
||||
|
||||
// CapsLock 켜짐
|
||||
act(() => {
|
||||
input.dispatchEvent(createKeyboardEvent('keydown', true));
|
||||
});
|
||||
expect(screen.getByText('CapsLock이 켜져 있습니다')).toBeInTheDocument();
|
||||
|
||||
// CapsLock 꺼짐
|
||||
act(() => {
|
||||
input.dispatchEvent(createKeyboardEvent('keyup', false));
|
||||
});
|
||||
expect(screen.queryByText('CapsLock이 켜져 있습니다')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('비밀번호가 보이는 상태에서는 CapsLock 경고가 표시되지 않아야 함', () => {
|
||||
render(<PasswordInput name="password" data-testid="password-input" />);
|
||||
const input = screen.getByTestId('password-input');
|
||||
const toggleButton = screen.getByRole('button', { name: /비밀번호 보기/i });
|
||||
|
||||
// CapsLock 켜짐
|
||||
act(() => {
|
||||
input.dispatchEvent(createKeyboardEvent('keydown', true));
|
||||
});
|
||||
|
||||
// 비밀번호 보기 활성화
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// CapsLock 경고가 표시되지 않아야 함 (showPassword=true면 경고 숨김)
|
||||
expect(screen.queryByText('CapsLock이 켜져 있습니다')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('CapsLock 경고 아이콘이 올바르게 표시되어야 함', () => {
|
||||
render(<PasswordInput name="password" data-testid="password-input" />);
|
||||
const input = screen.getByTestId('password-input');
|
||||
|
||||
act(() => {
|
||||
input.dispatchEvent(createKeyboardEvent('keydown', true));
|
||||
});
|
||||
|
||||
// triangle-exclamation 아이콘이 표시되어야 함
|
||||
expect(screen.getByTestId('icon-triangle-exclamation')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 기본 HTML 매핑 컴포넌트 Export
|
||||
*
|
||||
* DOM 요소에 직접 매핑되는 기본 컴포넌트들
|
||||
*/
|
||||
|
||||
export { Button, type ButtonProps } from './Button';
|
||||
export { FileInput, type FileInputProps } from './FileInput';
|
||||
export { Input, type InputProps } from './Input';
|
||||
export { PasswordInput, type PasswordInputProps, type PasswordRule, defaultPasswordRules, availablePasswordRules } from './PasswordInput';
|
||||
export { Textarea, type TextareaProps } from './Textarea';
|
||||
export { Label, type LabelProps } from './Label';
|
||||
export { Div, type DivProps } from './Div';
|
||||
export { Span, type SpanProps } from './Span';
|
||||
export { P, type PProps } from './P';
|
||||
export { Img, type ImgProps } from './Img';
|
||||
export { H1, type H1Props } from './H1';
|
||||
export { H2, type H2Props } from './H2';
|
||||
export { H3, type H3Props } from './H3';
|
||||
export { H4, type H4Props } from './H4';
|
||||
export { Ul, type UlProps } from './Ul';
|
||||
export { Ol, type OlProps } from './Ol';
|
||||
export { Li, type LiProps } from './Li';
|
||||
export { A, type AProps } from './A';
|
||||
export { Form, type FormProps } from './Form';
|
||||
export { Select, type SelectProps } from './Select';
|
||||
export { Option, type OptionProps } from './Option';
|
||||
export { Optgroup, type OptgroupProps } from './Optgroup';
|
||||
export { Checkbox, type CheckboxProps } from './Checkbox';
|
||||
export { Table, type TableProps } from './Table';
|
||||
export { Thead, type TheadProps } from './Thead';
|
||||
export { Tbody, type TbodyProps } from './Tbody';
|
||||
export { Tr, type TrProps } from './Tr';
|
||||
export { Th, type ThProps } from './Th';
|
||||
export { Td, type TdProps } from './Td';
|
||||
export { Nav, type NavProps } from './Nav';
|
||||
export { Section, type SectionProps } from './Section';
|
||||
export { Svg, type SvgProps } from './Svg';
|
||||
export { Icon, type IconProps } from './Icon';
|
||||
export { Code, type CodeProps } from './Code';
|
||||
export { Footer, type FooterProps } from './Footer';
|
||||
export { Header, type HeaderProps } from './Header';
|
||||
export { Hr, type HrProps } from './Hr';
|
||||
|
||||
// Icon 타입 정의 (enum 및 타입)
|
||||
export { IconName, type IconStyle, type IconSize } from './IconTypes';
|
||||
Reference in new issue
Block a user