v7.0.0 release

This commit is contained in:
HeuJung committed 2026-07-01 10:30:32 +09:00
1 parent 0e7fa04b03
commit c4ea9a6cd1
3582 files changed
+626189 -160151

No files matched your search

@@ -18,7 +18,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
className = '',
...props
}, ref) => {
const baseClasses = 'inline-flex items-center justify-center';
const baseClasses = 'button';
// G7Core.style.mergeClasses를 사용하여 충돌하는 Tailwind 클래스 병합
const mergedClassName = G7Core()?.style?.mergeClasses?.(baseClasses, className)
@@ -1,3 +1,4 @@
// e2e:allow편집기 passthrough 결함#2 수정(data-editor-*/id 를 숨겨진 input 대신 시각적 루트 div 로 라우팅). 라이브 검증은 Chrome MCP T1~T7 매트릭스(에디터 추가/선택/저장 200/reload/게스트 사용자화면), 단위 회귀는 FileInput.test.tsx passthrough describe.
import React, { forwardRef, useRef, useCallback, useState } from 'react';
export interface FileInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | 'onError'> {
@@ -35,6 +36,20 @@ export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(({
const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef;
const [fileName, setFileName] = useState<string>('');
// 레이아웃 편집기 식별 표식(data-editor-*) 과 DOM id 는 시각적 루트(<div>)에 부착해야
// 캔버스에서 선택/편집이 닿는다. basic 컴포넌트라 DynamicRenderer 가 개별 data-editor-*
// 키를 props 로 주입하는데, 숨겨진 <input class="hidden"> 는 0×0 이라 식별 표식이 닿아도
// 선택 불가. 식별/표식 키만 분리해 루트로 보낸다.
const rootEditorProps: Record<string, unknown> = {};
const inputProps: Record<string, unknown> = {};
Object.entries(props as Record<string, unknown>).forEach(([key, val]) => {
if (key.startsWith('data-editor-') || key === 'id' || key === 'onMouseMove' || key === 'onMouseLeave') {
rootEditorProps[key] = val;
} else {
inputProps[key] = val;
}
});
const handleClick = useCallback(() => {
if (!disabled && inputRef.current) {
inputRef.current.click();
@@ -76,7 +91,7 @@ export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(({
}, [onChange, inputRef]);
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className={`flex items-center gap-2 ${className}`} {...rootEditorProps}>
<input
ref={inputRef}
type="file"
@@ -84,7 +99,7 @@ export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(({
onChange={handleChange}
disabled={disabled}
className="hidden"
{...props}
{...inputProps}
/>
<button
type="button"
@@ -1,3 +1,4 @@
// e2e:allow basic Input 디폴트 시맨틱(.input/.checkbox/.radio) 머지 — 동작 무변, 외형 통일 시범. 패턴 확정 후 다음 사이클에 E2E spec 일괄 작성 예정.
import React, { useRef, useCallback, useState, useEffect, forwardRef, useImperativeHandle } from 'react';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
@@ -5,6 +6,24 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
error?: string;
}
// 타입별 디폴트 시맨틱 클래스 (admin main.css 정의)
// 호출처 className 은 뒤에 머지되어 덮어쓸 수 있음 (의미 토큰/override 모두 지원)
const resolveDefaultSemanticClass = (type: string): string => {
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
return 'input';
};
// 호출처 className 에 이미 시맨틱 토큰이 포함된 경우 prepend 를 스킵하여
// "input input" 같은 중복 발생을 방지. 기존 호출처가 명시적으로 시맨틱을 적던 패턴 호환.
const composeSemanticClassName = (type: string, className: string): string => {
const semantic = resolveDefaultSemanticClass(type);
if (!className) return semantic;
const tokens = className.split(/\s+/).filter(Boolean);
if (tokens.includes(semantic)) return className;
return `${semantic} ${className}`;
};
/**
* 기본 입력 필드 컴포넌트
*
@@ -30,6 +49,10 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
// radio/checkbox 타입인지 확인
const isCheckableType = type === 'radio' || type === 'checkbox';
// 디폴트 시맨틱 + 호출처 className 머지 (호출처가 의미 토큰만 명시해도 시맨틱은 자동 적용)
// 호출처가 이미 시맨틱 토큰을 명시한 경우 중복 prepend 방지.
const mergedClassName = composeSemanticClassName(type, className);
// IME 조합 중인지 추적 (text input에만 필요)
const isComposingRef = useRef(false);
// input 요소 참조
@@ -125,7 +148,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
<input
ref={internalRef}
type={type}
className={className}
className={mergedClassName}
onChange={onChange}
checked={checked}
defaultChecked={defaultChecked}
@@ -144,7 +167,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
<input
ref={internalRef}
type={type}
className={className}
className={mergedClassName}
onChange={handleChange}
onKeyPress={handleKeyPress}
onCompositionStart={handleCompositionStart}
@@ -1,3 +1,4 @@
// e2e:allow편집기 passthrough 결함#1 수정(커스텀 드롭다운 루트 data-editor-* spread). 라이브 검증은 Chrome MCP T1~T7 매트릭스(에디터 추가/선택/저장 200/reload/게스트 사용자화면)로 수행, 단위 회귀는 Select.test.tsx passthrough describe.
import React, { useMemo, useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Div } from './Div';
@@ -28,6 +29,12 @@ export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectE
searchable?: boolean;
/** 검색 input placeholder */
searchPlaceholder?: string;
/**
* 레이아웃 편집기 주입 속성 (편집 모드 전용). Select 는 커스텀 드롭다운 루트에
* 개별 data-editor-* 키를 spread 해야 선택/편집이 닿는다(커스텀 루트
* passthrough). basic 컴포넌트라 `{...props}` 로 DOM 표준 속성(id 등)도 함께 흐른다.
*/
editorAttrs?: Record<string, unknown>;
}
/**
@@ -67,6 +74,7 @@ export const Select: React.FC<SelectProps> = ({
disabled,
searchable = false,
searchPlaceholder,
editorAttrs,
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
@@ -230,13 +238,20 @@ export const Select: React.FC<SelectProps> = ({
};
// options가 없으면 기본 select 사용 (children 지원)
// .select 시맨틱(admin main.css)을 디폴트로 머지 → 호출처가 className 미지정해도 표준 외형
// 호출처가 이미 'select' 토큰을 명시한 경우 중복 prepend 방지.
if (!normalizedOptions) {
const tokens = (className ?? '').split(/\s+/).filter(Boolean);
const mergedClassName = tokens.includes('select')
? (className ?? '')
: ['select', className].filter(Boolean).join(' ');
return (
<select
className={className}
className={mergedClassName}
value={value}
onChange={onChange as React.ChangeEventHandler<HTMLSelectElement>}
disabled={disabled}
{...editorAttrs}
{...props}
>
{children}
@@ -245,15 +260,27 @@ export const Select: React.FC<SelectProps> = ({
}
// 기본 스타일 (className이 없을 때 적용)
const hasCustomStyle = className && className.includes('bg-');
// variant 접두사가 붙은 유틸(disabled:bg-, dark:bg-, hover:bg- 등)은 베이스 외형이 아니므로
// hasCustomStyle/hasTextColor 판정에서 제외한다. 베이스 토큰만으로 커스텀 외형 여부를 판단해야
// 호출처가 disabled:bg-gray-100 만 추가했을 때 기본 크기 클래스(w-full px-4 py-2.5)가 유실되지 않는다.
const baseTokens = (className ?? '')
.split(/\s+/)
.filter((t) => t && !t.includes(':')) // variant 접두사(`disabled:`, `dark:`, `hover:` 등) 토큰 제외
.join(' ');
const hasCustomStyle = baseTokens.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 hasTextColor = /text-(gray|red|blue|green|yellow|white|black|indigo|purple|pink|orange|amber|emerald|teal|cyan|slate)-\d+|text-white|text-black/.test(baseTokens);
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}`;
: `w-full px-4 py-2.5 bg-gray-100 dark:bg-gray-700 border-0 rounded-xl text-sm text-gray-700 dark:text-gray-200 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none ${className}`;
// props 는 SelectHTMLAttributes 기반이라 Div(HTMLDivElement) 에 직접 spread 시 타입 불일치.
// 런타임 동작(편집기 data-editor-* / id 등 DOM 표준 속성 passthrough)은 동일하므로
// DOM-safe 레코드로 캐스팅해 커스텀 드롭다운 루트에 전달한다.
const rootDomProps = { ...editorAttrs, ...props } as Record<string, unknown>;
return (
<Div ref={containerRef} className="relative">
<Div ref={containerRef} className="relative" {...rootDomProps}>
<Button
ref={buttonRef}
type="button"
@@ -309,7 +336,7 @@ export const Select: React.FC<SelectProps> = ({
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 ${
className={`w-full px-4 py-3 text-left text-sm 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"
@@ -1,3 +1,4 @@
// e2e:allow basic Textarea 디폴트 시맨틱(.textarea) 머지 — 외형만 변경, 동작 무변. 다음 사이클에 다른 composite 와 함께 E2E spec 일괄 작성 예정.
import React, { useRef, useCallback, useState, useEffect } from 'react';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
@@ -92,10 +93,17 @@ export const Textarea: React.FC<TextareaProps> = ({
? { value: localValue }
: { defaultValue };
// .textarea 시맨틱(admin main.css)을 디폴트로 머지 → 호출처가 className 미지정해도 표준 외형
// 호출처가 이미 'textarea' 토큰을 명시한 경우 중복 prepend 방지.
const textareaTokens = (className ?? '').split(/\s+/).filter(Boolean);
const mergedClassName = textareaTokens.includes('textarea')
? (className ?? '')
: ['textarea', className].filter(Boolean).join(' ');
return (
<textarea
ref={textareaRef}
className={className}
className={mergedClassName}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
@@ -0,0 +1,188 @@
import React from 'react';
import { Icon } from './Icon';
import { IconSize } from './IconTypes';
/**
* UnifiedIcon 크기 타입
*/
export type UnifiedIconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/**
* UnifiedIcon Props
*/
export interface UnifiedIconProps {
/**
* 아이콘 지정자
*
* @example
* // Font Awesome (기본)
* "fa:cart-shopping"
* "fa-solid fa-cart-shopping"
* "cart-shopping" // 접두사 없으면 FA 기본
*
* // 외부 SVG 파일
* "svg:/modules/my-module/assets/icon.svg"
*
* // 이미지 파일
* "img:/images/custom-icon.png"
*/
icon: string;
/**
* 아이콘 크기
* - xs: 12px (w-3 h-3)
* - sm: 16px (w-4 h-4)
* - md: 20px (w-5 h-5) - 기본값
* - lg: 24px (w-6 h-6)
* - xl: 32px (w-8 h-8)
*
* @default 'md'
*/
size?: UnifiedIconSize;
/**
* 추가 CSS 클래스
*/
className?: string;
/**
* 색상 (Tailwind 클래스)
*
* @example
* "text-blue-500"
* "text-slate-600 dark:text-slate-300"
*/
color?: string;
/**
* 접근성 레이블
*/
ariaLabel?: string;
}
/**
* 크기별 Tailwind 클래스 매핑
*/
const sizeClassMap: Record<UnifiedIconSize, string> = {
xs: 'w-3 h-3', // 12px
sm: 'w-4 h-4', // 16px
md: 'w-5 h-5', // 20px
lg: 'w-6 h-6', // 24px
xl: 'w-8 h-8', // 32px
};
/**
* Icon 컴포넌트용 크기 매핑 (Font Awesome size prop)
*/
const iconSizeMap: Record<UnifiedIconSize, IconSize | undefined> = {
xs: 'xs',
sm: 'sm',
md: undefined, // 기본값
lg: 'lg',
xl: '2x',
};
/**
* UnifiedIcon 컴포넌트
*
* 다양한 아이콘 소스(Font Awesome, SVG, 이미지)를 통합하여 렌더링하는 컴포넌트입니다.
*
* @example
* // Font Awesome 아이콘 (기본)
* <UnifiedIcon icon="fa:cart-shopping" size="md" />
* <UnifiedIcon icon="cart-shopping" /> // 접두사 없으면 FA 기본
*
* // 외부 SVG 파일
* <UnifiedIcon icon="svg:/modules/my-module/assets/icon.svg" size="lg" />
*
* // 이미지 파일
* <UnifiedIcon icon="img:/images/custom-icon.png" size="sm" />
*/
export const UnifiedIcon: React.FC<UnifiedIconProps> = ({
icon,
size = 'md',
className = '',
color,
ariaLabel,
}) => {
// 타입 파싱: "type:value" 형식 분리
const colonIndex = icon.indexOf(':');
// "fa-solid fa-xxx" 또는 "fas fa-xxx" 형식인 경우 Font Awesome으로 처리
const isFontAwesomeClass =
icon.startsWith('fa-') ||
icon.startsWith('fas ') ||
icon.startsWith('far ') ||
icon.startsWith('fab ') ||
icon.startsWith('fal ') ||
icon.startsWith('fad ');
let type: string;
let name: string;
if (isFontAwesomeClass) {
// Font Awesome 클래스 형식
type = 'fa';
name = icon;
} else if (colonIndex > 0) {
// "type:name" 형식
type = icon.substring(0, colonIndex);
name = icon.substring(colonIndex + 1);
} else {
// 접두사 없음 = Font Awesome 기본
type = 'fa';
name = icon;
}
const sizeClass = sizeClassMap[size];
const colorClass = color || '';
switch (type) {
case 'fa':
// Font Awesome: Icon 컴포넌트 위임
return (
<Icon
name={name}
size={iconSizeMap[size]}
className={className}
color={color}
ariaLabel={ariaLabel}
/>
);
case 'svg':
// 외부 SVG 파일 (img 태그로 렌더링)
return (
<img
src={name}
className={`${sizeClass} ${colorClass} ${className}`.trim()}
alt={ariaLabel || ''}
aria-label={ariaLabel}
role="img"
/>
);
case 'img':
// 이미지 파일
return (
<img
src={name}
className={`${sizeClass} ${className}`.trim()}
alt={ariaLabel || ''}
role="img"
/>
);
default:
// 기본: Font Awesome
return (
<Icon
name={icon}
size={iconSizeMap[size]}
className={className}
color={color}
ariaLabel={ariaLabel}
/>
);
}
};
@@ -219,4 +219,49 @@ describe('FileInput 컴포넌트', () => {
expect(handleChange).toHaveBeenCalledWith({ target: { value: null, name: '' } });
});
});
// ========================================
// 레이아웃 편집기 passthrough
// ========================================
// 편집 모드에서 basic 컴포넌트에 주입되는 data-editor-* / id 는 시각적 루트(<div>)에
// 부착되어야 한다. 숨겨진 <input class="hidden"> (0×0) 에 부착되면 캔버스에서 선택 불가.
describe('편집기 passthrough (data-editor-* / id → 시각적 루트 <div>)', () => {
it('data-editor-* 표식이 숨겨진 input 이 아닌 루트 div 에 부착된다', () => {
const { container } = render(
<FileInput
data-editor-name="FileInput"
data-editor-path="1.children.0"
id="fi-root-id"
/>
);
// 루트는 visible div(파일 input 의 부모 래퍼)
const root = container.querySelector('[data-editor-name="FileInput"]') as HTMLElement;
expect(root).toBeTruthy();
expect(root.tagName.toLowerCase()).toBe('div');
expect(root.getAttribute('data-editor-path')).toBe('1.children.0');
expect(root.getAttribute('id')).toBe('fi-root-id');
// 루트는 버튼(Browse 트리거)을 자식으로 가진다 = 시각적 루트 확인
expect(root.querySelector('button')).toBeTruthy();
});
it('숨겨진 file input 에는 편집 표식이 없다', () => {
const { container } = render(
<FileInput data-editor-name="FileInput" data-editor-path="2.children.1" id="fi-id" />
);
const hiddenInput = container.querySelector('input[type="file"]') as HTMLElement;
expect(hiddenInput).toBeTruthy();
expect(hiddenInput.getAttribute('data-editor-name')).toBeNull();
expect(hiddenInput.getAttribute('data-editor-path')).toBeNull();
expect(hiddenInput.getAttribute('id')).toBeNull();
});
it('기능 prop(name 등)은 file input 에 그대로 전달된다', () => {
const { container } = render(
<FileInput name="upload_field" data-editor-name="FileInput" />
);
const hiddenInput = container.querySelector('input[type="file"]') as HTMLInputElement;
expect(hiddenInput.getAttribute('name')).toBe('upload_field');
});
});
});
@@ -1,3 +1,4 @@
// e2e:allow 단위 테스트 보강 (composeSemanticClassName 머지 회귀) — 동작 무변, 컴포넌트 소스의 e2e:allow 와 동일 사이클에서 E2E spec 일괄 작성 예정.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -203,6 +204,56 @@ describe('Input 컴포넌트', () => {
// autoFocus는 jsdom 환경에서 제대로 테스트되지 않으므로 생략
});
// composeSemanticClassName 회귀 테스트 (#369)
// 호출처가 이미 시맨틱 토큰을 명시한 경우 컴포넌트가 무조건 prepend 하여
// "input input ..." 으로 중복되던 결함 해소를 검증한다.
describe('디폴트 시맨틱 클래스 머지', () => {
it('className 미지정 시 디폴트 시맨틱 "input" 이 자동 적용된다', () => {
const { container } = render(<Input />);
const input = container.querySelector('input');
expect(input?.className).toBe('input');
});
it('className 에 시맨틱 토큰이 없으면 "input" 을 prepend 한다', () => {
const { container } = render(<Input className="font-mono w-full" />);
const input = container.querySelector('input');
expect(input?.className).toBe('input font-mono w-full');
});
it('className 에 이미 "input" 토큰이 있으면 중복 prepend 하지 않는다', () => {
const { container } = render(<Input className="input font-mono" />);
const input = container.querySelector('input');
// "input input font-mono" 로 중복되지 않아야 한다
expect(input?.className).toBe('input font-mono');
expect(input?.className.split(/\s+/).filter((t) => t === 'input')).toHaveLength(1);
});
it('input-readonly 처럼 다른 시맨틱과 조합 시 "input" 이 prepend 된다', () => {
const { container } = render(<Input className="input-readonly font-mono" />);
const input = container.querySelector('input');
// "input-readonly" 는 "input" 토큰과 다르므로 prepend 발생
expect(input?.className).toBe('input input-readonly font-mono');
});
it('checkbox 타입은 디폴트 시맨틱 "checkbox" 가 적용된다', () => {
const { container } = render(<Input type="checkbox" />);
const input = container.querySelector('input');
expect(input?.className).toBe('checkbox');
});
it('radio 타입은 디폴트 시맨틱 "radio" 가 적용된다', () => {
const { container } = render(<Input type="radio" />);
const input = container.querySelector('input');
expect(input?.className).toBe('radio');
});
it('checkbox 타입에 "checkbox" 토큰이 이미 있으면 중복되지 않는다', () => {
const { container } = render(<Input type="checkbox" className="checkbox accent-blue-500" />);
const input = container.querySelector('input');
expect(input?.className).toBe('checkbox accent-blue-500');
});
});
describe('복합 Props', () => {
it('여러 props가 함께 적용된다', () => {
const handleChange = vi.fn();
@@ -1,3 +1,4 @@
// e2e:allow 단위 테스트 보강 (디폴트 시맨틱 머지 회귀) — 동작 무변, 컴포넌트 소스의 e2e:allow 와 동일 사이클에서 E2E spec 일괄 작성 예정.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
@@ -242,6 +243,41 @@ describe('Select 컴포넌트', () => {
});
});
// 디폴트 시맨틱 머지 회귀 테스트 (#369)
// 호출처가 이미 'select' 토큰을 명시한 경우 중복 prepend ("select select ...") 를 방지한다.
describe('디폴트 시맨틱 클래스 머지 (children 모드)', () => {
it('className 미지정 시 디폴트 시맨틱 "select" 가 자동 적용된다', () => {
const { container } = render(
<Select>
<option value="1">옵션 1</option>
</Select>
);
const select = container.querySelector('select');
expect(select?.className).toBe('select');
});
it('className 에 시맨틱 토큰이 없으면 "select" 를 prepend 한다', () => {
const { container } = render(
<Select className="w-full">
<option value="1">옵션 1</option>
</Select>
);
const select = container.querySelector('select');
expect(select?.className).toBe('select w-full');
});
it('className 에 이미 "select" 토큰이 있으면 중복 prepend 하지 않는다', () => {
const { container } = render(
<Select className="select w-full">
<option value="1">옵션 1</option>
</Select>
);
const select = container.querySelector('select');
expect(select?.className).toBe('select w-full');
expect(select?.className.split(/\s+/).filter((t) => t === 'select')).toHaveLength(1);
});
});
describe('optgroup 지원', () => {
it('optgroup이 렌더링된다', () => {
render(
@@ -734,4 +770,129 @@ describe('Select 컴포넌트', () => {
expect(input.value).toBe('');
});
});
// ========================================
// 레이아웃 편집기 editorAttrs/props passthrough
// ========================================
// DynamicRenderer 는 편집 모드에서 basic 컴포넌트에 개별 data-editor-* 키를
// props 로 주입한다. Select 의 두 렌더 경로(children 폴백 <select>, options 커스텀 <Div>)
// 루트 모두 그 키가 DOM 에 도달해야 캔버스에서 선택/식별/편집이 가능하다.
describe('편집기 passthrough (editorAttrs/props → 루트 DOM)', () => {
it('options 커스텀 드롭다운 루트에 data-editor-* 가 도달한다', () => {
const options = [
{ value: '1', label: '옵션 1' },
{ value: '2', label: '옵션 2' },
];
const { container } = render(
<Select
options={options}
value="1"
onChange={() => {}}
data-editor-name="Select"
data-editor-path="1.children.0"
id="root-id"
/>
);
// 커스텀 드롭다운 루트는 button 의 부모 Div (relative)
const root = container.querySelector('[data-editor-name="Select"]') as HTMLElement;
expect(root).toBeTruthy();
expect(root.getAttribute('data-editor-path')).toBe('1.children.0');
expect(root.getAttribute('id')).toBe('root-id');
// 루트는 button(드롭다운 트리거)을 자식으로 가진다 = 커스텀 모드 루트 확인
expect(root.querySelector('button[aria-haspopup="listbox"]')).toBeTruthy();
});
it('children 폴백 <select> 루트에 data-editor-* 가 도달한다', () => {
const { container } = render(
<Select data-editor-name="Select" data-editor-path="1.children.1" id="sel-id">
<option value="1">옵션 1</option>
</Select>
);
const select = container.querySelector('select') as HTMLElement;
expect(select).toBeTruthy();
expect(select.getAttribute('data-editor-name')).toBe('Select');
expect(select.getAttribute('data-editor-path')).toBe('1.children.1');
expect(select.getAttribute('id')).toBe('sel-id');
});
it('editorAttrs 객체로 전달된 키도 루트에 도달한다', () => {
const options = [{ value: '1', label: '옵션 1' }];
const { container } = render(
<Select
options={options}
value="1"
onChange={() => {}}
editorAttrs={{ 'data-editor-name': 'Select', 'data-editor-path': '2.children.3' }}
/>
);
const root = container.querySelector('[data-editor-name="Select"]') as HTMLElement;
expect(root).toBeTruthy();
expect(root.getAttribute('data-editor-path')).toBe('2.children.3');
});
});
describe('variant 접두사 className 과 베이스 크기 유지 (비활성 정책 Select 회귀)', () => {
/**
* 회귀 배경: 정책 편집 모달의 잠금 Select 는 className 에 `disabled:bg-gray-100` 만 추가한다.
* hasCustomStyle 판정이 `bg-` 단순 포함으로 동작하면 variant 접두사 유틸(disabled:bg-)을
* 커스텀 베이스 외형으로 오인 → 기본 크기 클래스(w-full px-4 py-2.5)를 통째로 버려
* 비활성 Select 만 작아지고 활성 Select 와 크기/모양이 달라진다.
*/
const getTriggerBtn = (container: HTMLElement) =>
container.querySelector('button[aria-haspopup="listbox"]') as HTMLElement;
it('disabled:bg- 만 추가된 비활성 Select 도 베이스 크기 클래스(w-full px-4 py-2.5) 유지', () => {
const options = [{ value: 'sensitive_action', label: '민감 작업' }];
const { container } = render(
<Select
options={options}
value="sensitive_action"
onChange={() => {}}
disabled
className=" disabled:bg-gray-100 dark:disabled:bg-gray-900"
/>
);
const btn = getTriggerBtn(container);
expect(btn).toBeTruthy();
expect(btn.className).toContain('w-full');
expect(btn.className).toContain('px-4');
expect(btn.className).toContain('py-2.5');
});
it('활성 Select(동일 컬럼)와 비활성 Select 의 베이스 크기 클래스가 동일', () => {
const options = [{ value: '', label: '(기본 인증 수단 사용)' }];
const active = render(
<Select options={options} value="" onChange={() => {}} className="" />
);
const disabled = render(
<Select
options={options}
value=""
onChange={() => {}}
disabled
className=" disabled:bg-gray-100 dark:disabled:bg-gray-900"
/>
);
const pick = (cls: string) =>
['w-full', 'px-4', 'py-2.5'].filter((t) => cls.split(/\s+/).includes(t)).sort().join(' ');
const activeBase = pick(getTriggerBtn(active.container).className);
const disabledBase = pick(getTriggerBtn(disabled.container).className);
expect(disabledBase).toBe(activeBase);
expect(disabledBase).toBe('px-4 py-2.5 w-full');
});
it('실제 커스텀 베이스 bg-(접두사 없음)는 기존대로 기본 크기 미적용', () => {
const options = [{ value: '1', label: '옵션 1' }];
const { container } = render(
<Select options={options} value="1" onChange={() => {}} className="bg-indigo-600 px-2" />
);
const btn = getTriggerBtn(container);
// 베이스 bg- 가 있으면 호출처 className 을 그대로 존중(w-full 자동 주입 안 함)
expect(btn.className).toContain('bg-indigo-600');
expect(btn.className).not.toContain('w-full');
});
});
});
@@ -1,3 +1,4 @@
// e2e:allow 단위 테스트 보강 (디폴트 시맨틱 머지 회귀) — 동작 무변, 컴포넌트 소스의 e2e:allow 와 동일 사이클에서 E2E spec 일괄 작성 예정.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
@@ -275,4 +276,27 @@ describe('Textarea 컴포넌트', () => {
expect(textarea.value).toBe(multilineText);
});
});
// 디폴트 시맨틱 머지 회귀 테스트 (#369)
// 호출처가 이미 'textarea' 토큰을 명시한 경우 중복 prepend ("textarea textarea ...") 를 방지한다.
describe('디폴트 시맨틱 클래스 머지', () => {
it('className 미지정 시 디폴트 시맨틱 "textarea" 가 자동 적용된다', () => {
const { container } = render(<Textarea />);
const textarea = container.querySelector('textarea');
expect(textarea?.className).toBe('textarea');
});
it('className 에 시맨틱 토큰이 없으면 "textarea" 를 prepend 한다', () => {
const { container } = render(<Textarea className="resize-vertical" />);
const textarea = container.querySelector('textarea');
expect(textarea?.className).toBe('textarea resize-vertical');
});
it('className 에 이미 "textarea" 토큰이 있으면 중복 prepend 하지 않는다', () => {
const { container } = render(<Textarea className="textarea resize-vertical" />);
const textarea = container.querySelector('textarea');
expect(textarea?.className).toBe('textarea resize-vertical');
expect(textarea?.className.split(/\s+/).filter((t) => t === 'textarea')).toHaveLength(1);
});
});
});
@@ -0,0 +1,198 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UnifiedIcon } from '../UnifiedIcon';
describe('UnifiedIcon 컴포넌트', () => {
describe('Font Awesome 아이콘', () => {
it('fa: 접두사로 아이콘이 렌더링된다', () => {
const { container } = render(<UnifiedIcon icon="fa:cart-shopping" />);
const icon = container.querySelector('i');
expect(icon).toBeTruthy();
expect(icon?.className).toContain('fa-cart-shopping');
});
it('접두사 없이 Font Awesome 아이콘이 렌더링된다', () => {
const { container } = render(<UnifiedIcon icon="user" />);
const icon = container.querySelector('i');
expect(icon).toBeTruthy();
expect(icon?.className).toContain('fa-user');
});
it('fa-solid fa-xxx 형식이 지원된다', () => {
const { container } = render(<UnifiedIcon icon="fa-solid fa-cart-shopping" />);
const icon = container.querySelector('i');
expect(icon).toBeTruthy();
expect(icon?.className).toContain('fa-cart-shopping');
});
it('fas fa-xxx 형식이 지원된다', () => {
const { container } = render(<UnifiedIcon icon="fas fa-user" />);
const icon = container.querySelector('i');
expect(icon).toBeTruthy();
expect(icon?.className).toContain('fa-user');
});
it('far fa-xxx 형식이 지원된다', () => {
const { container } = render(<UnifiedIcon icon="far fa-star" />);
const icon = container.querySelector('i');
expect(icon).toBeTruthy();
expect(icon?.className).toContain('fa-star');
});
});
describe('SVG 아이콘', () => {
it('svg: 접두사로 이미지가 렌더링된다', () => {
render(<UnifiedIcon icon="svg:/path/to/icon.svg" ariaLabel="Custom Icon" />);
const img = screen.getByRole('img');
expect(img).toBeTruthy();
expect(img.getAttribute('src')).toBe('/path/to/icon.svg');
});
it('SVG 아이콘에 크기 클래스가 적용된다', () => {
render(<UnifiedIcon icon="svg:/icon.svg" size="lg" ariaLabel="Icon" />);
const img = screen.getByRole('img');
expect(img.className).toContain('w-6');
expect(img.className).toContain('h-6');
});
it('SVG 아이콘에 색상 클래스가 적용된다', () => {
render(<UnifiedIcon icon="svg:/icon.svg" color="text-blue-500" ariaLabel="Icon" />);
const img = screen.getByRole('img');
expect(img.className).toContain('text-blue-500');
});
});
describe('이미지 아이콘', () => {
it('img: 접두사로 이미지가 렌더링된다', () => {
render(<UnifiedIcon icon="img:/path/to/icon.png" ariaLabel="Custom Icon" />);
const img = screen.getByRole('img');
expect(img).toBeTruthy();
expect(img.getAttribute('src')).toBe('/path/to/icon.png');
});
it('이미지 아이콘에 크기 클래스가 적용된다', () => {
render(<UnifiedIcon icon="img:/icon.png" size="sm" ariaLabel="Icon" />);
const img = screen.getByRole('img');
expect(img.className).toContain('w-4');
expect(img.className).toContain('h-4');
});
});
describe('크기', () => {
it('xs 크기가 적용된다 (FA)', () => {
const { container } = render(<UnifiedIcon icon="user" size="xs" />);
const icon = container.querySelector('i');
expect(icon?.className).toContain('fa-xs');
});
it('sm 크기가 적용된다 (FA)', () => {
const { container } = render(<UnifiedIcon icon="user" size="sm" />);
const icon = container.querySelector('i');
expect(icon?.className).toContain('fa-sm');
});
it('md 크기가 기본값이다 (FA)', () => {
const { container } = render(<UnifiedIcon icon="user" />);
const icon = container.querySelector('i');
// md는 기본값이므로 fa-md 클래스가 없음
expect(icon?.className).not.toContain('fa-md');
expect(icon?.className).not.toContain('fa-lg');
});
it('lg 크기가 적용된다 (FA)', () => {
const { container } = render(<UnifiedIcon icon="user" size="lg" />);
const icon = container.querySelector('i');
expect(icon?.className).toContain('fa-lg');
});
it('xl 크기가 적용된다 (FA)', () => {
const { container } = render(<UnifiedIcon icon="user" size="xl" />);
const icon = container.querySelector('i');
expect(icon?.className).toContain('fa-2x');
});
it.each([
['xs', 'w-3', 'h-3'],
['sm', 'w-4', 'h-4'],
['md', 'w-5', 'h-5'],
['lg', 'w-6', 'h-6'],
['xl', 'w-8', 'h-8'],
] as const)('%s 크기가 SVG에 적용된다', (size, expectedWidth, expectedHeight) => {
render(<UnifiedIcon icon="svg:/icon.svg" size={size} ariaLabel="Icon" />);
const img = screen.getByRole('img');
expect(img.className).toContain(expectedWidth);
expect(img.className).toContain(expectedHeight);
});
});
describe('색상', () => {
it('FA 아이콘에 색상이 적용된다', () => {
const { container } = render(<UnifiedIcon icon="user" color="text-blue-500" />);
const icon = container.querySelector('i');
expect(icon?.className).toContain('text-blue-500');
});
});
describe('추가 className', () => {
it('FA 아이콘에 추가 className이 적용된다', () => {
const { container } = render(<UnifiedIcon icon="user" className="custom-class" />);
const icon = container.querySelector('i');
expect(icon?.className).toContain('custom-class');
});
it('SVG 아이콘에 추가 className이 적용된다', () => {
render(<UnifiedIcon icon="svg:/icon.svg" className="custom-class" ariaLabel="Icon" />);
const img = screen.getByRole('img');
expect(img.className).toContain('custom-class');
});
it('이미지 아이콘에 추가 className이 적용된다', () => {
render(<UnifiedIcon icon="img:/icon.png" className="custom-class" ariaLabel="Icon" />);
const img = screen.getByRole('img');
expect(img.className).toContain('custom-class');
});
});
describe('접근성', () => {
it('FA 아이콘에 ariaLabel이 적용된다', () => {
const { container } = render(<UnifiedIcon icon="user" ariaLabel="사용자 아이콘" />);
const icon = container.querySelector('i');
expect(icon?.getAttribute('aria-label')).toBe('사용자 아이콘');
});
it('SVG 아이콘에 ariaLabel이 적용된다', () => {
render(<UnifiedIcon icon="svg:/icon.svg" ariaLabel="커스텀 아이콘" />);
const img = screen.getByRole('img');
expect(img.getAttribute('aria-label')).toBe('커스텀 아이콘');
});
it('이미지 아이콘에 alt 속성이 적용된다', () => {
render(<UnifiedIcon icon="img:/icon.png" ariaLabel="이미지 아이콘" />);
const img = screen.getByRole('img');
expect(img.getAttribute('alt')).toBe('이미지 아이콘');
});
});
describe('타입 파싱', () => {
it('알 수 없는 접두사는 FA로 폴백된다', () => {
const { container } = render(<UnifiedIcon icon="unknown:something" />);
const icon = container.querySelector('i');
// 알 수 없는 타입이므로 default case에서 Icon 컴포넌트로 렌더링
expect(icon).toBeTruthy();
});
it('콜론이 없는 문자열은 FA로 처리된다', () => {
const { container } = render(<UnifiedIcon icon="cart-shopping" />);
const icon = container.querySelector('i');
expect(icon).toBeTruthy();
expect(icon?.className).toContain('fa-cart-shopping');
});
it('빈 접두사는 FA로 처리된다', () => {
const { container } = render(<UnifiedIcon icon=":something" />);
// 콜론 인덱스가 0이므로 FA 기본 처리
const icon = container.querySelector('i');
expect(icon).toBeTruthy();
});
});
});
@@ -37,10 +37,12 @@ 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 { UnifiedIcon, type UnifiedIconProps, type UnifiedIconSize } from './UnifiedIcon';
export { Code, type CodeProps } from './Code';
export { Pre, type PreProps } from './Pre';
export { Fragment, type FragmentProps } from './Fragment';
export { Hr, type HrProps } from './Hr';
export { I, type IProps } from './I';
// Icon 타입 정의 (enum 및 타입)
export { IconName, type IconStyle, type IconSize } from './IconTypes';