v7.0.0 release
This commit is contained in:
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
+45
@@ -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();
|
||||
|
||||
+161
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+24
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+198
@@ -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';
|
||||
Reference in new issue
Block a user