v7.0.0-beta.1 release

This commit is contained in:
HeuJung committed 2026-04-01 10:30:52 +09:00
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>
);
};
@@ -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';