v7.0.0-beta.2 release

This commit is contained in:
HeuJung committed 2026-04-20 20:37:49 +09:00
1 parent 6595fd0eb5
commit fa8eba7a0d
1165 files changed
+107118 -762127

No files matched your search

@@ -5,8 +5,8 @@ export interface FileInputProps extends Omit<React.InputHTMLAttributes<HTMLInput
accept?: string;
/** 최대 파일 크기 (MB) */
maxSize?: number;
/** 파일 선택 시 콜백 */
onChange?: (file: File | null) => void;
/** 파일 선택 시 콜백 ({ target: { value } } 패턴) */
onChange?: (event: { target: { value: File | null; name: string } }) => void;
/** 에러 발생 시 콜백 */
onError?: (error: string) => void;
/** 버튼 텍스트 */
@@ -46,7 +46,7 @@ export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(({
if (!file) {
setFileName('');
onChange?.(null);
onChange?.({ target: { value: null, name: '' } });
return;
}
@@ -63,7 +63,7 @@ export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(({
}
setFileName(file.name);
onChange?.(file);
onChange?.({ target: { value: file, name: file.name } });
}, [maxSize, onChange, onError, inputRef]);
const handleClear = useCallback((e: React.MouseEvent) => {
@@ -72,7 +72,7 @@ export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(({
inputRef.current.value = '';
}
setFileName('');
onChange?.(null);
onChange?.({ target: { value: null, name: '' } });
}, [onChange, inputRef]);
return (
@@ -1,8 +1,10 @@
import React, { useMemo, useState, useRef, useEffect } from 'react';
import React, { useMemo, useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Div } from './Div';
import { Button } from './Button';
import { Span } from './Span';
import { Svg } from './Svg';
import { Input } from './Input';
// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)
const logger = ((window as any).G7Core?.createLogger?.('Comp:Select')) ?? {
@@ -22,6 +24,10 @@ export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectE
error?: string;
options?: SelectOption[] | string[];
onChange?: (e: React.ChangeEvent<HTMLSelectElement> | { target: { value: string | number } }) => void;
/** 드롭다운 내 검색 input 활성화 (engine-v1.40.0+) */
searchable?: boolean;
/** 검색 input placeholder */
searchPlaceholder?: string;
}
/**
@@ -56,11 +62,17 @@ export const Select: React.FC<SelectProps> = ({
value,
onChange,
disabled,
searchable = false,
searchPlaceholder,
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [dropdownPos, setDropdownPos] = useState<{ top?: number; bottom?: number; left: number; width: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// options를 SelectOption[] 형식으로 정규화
const normalizedOptions = useMemo((): SelectOption[] | null => {
@@ -94,10 +106,79 @@ export const Select: React.FC<SelectProps> = ({
return selected?.label || '';
}, [normalizedOptions, value]);
// 외부 클릭 시 드롭다운 닫기
// searchable 모드에서 검색어로 필터링된 옵션
const visibleOptions = useMemo((): SelectOption[] | null => {
if (!normalizedOptions) return null;
if (!searchable || searchTerm.trim() === '') return normalizedOptions;
const needle = searchTerm.trim().toLowerCase();
return normalizedOptions.filter(opt =>
opt.label.toLowerCase().includes(needle) ||
String(opt.value).toLowerCase().includes(needle)
);
}, [normalizedOptions, searchable, searchTerm]);
// 드롭다운 열릴 때 검색어 초기화 및 input 포커스
useEffect(() => {
if (isOpen && searchable) {
setSearchTerm('');
// 다음 tick에 포커스 (DOM 렌더 이후)
const timer = setTimeout(() => searchInputRef.current?.focus(), 0);
return () => clearTimeout(timer);
}
if (!isOpen) {
setSearchTerm('');
}
}, [isOpen, searchable]);
/**
* 드롭다운 위치 계산 (버튼 기준 fixed positioning)
* Modal 등 overflow:hidden 컨테이너 내부에서도 정상 표시
*/
const updateDropdownPos = useCallback(() => {
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const dropdownMaxH = 280; // max-h-60(240px) + search(~40px)
// 아래 공간 부족 시 위로 열기
const openUpward = spaceBelow < dropdownMaxH && rect.top > spaceBelow;
if (openUpward) {
setDropdownPos({
bottom: window.innerHeight - rect.top + 4,
left: rect.left,
width: rect.width,
});
} else {
setDropdownPos({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
});
}
}, []);
// 드롭다운 열릴 때 위치 계산 + 스크롤/리사이즈 추적
useEffect(() => {
if (!isOpen) {
setDropdownPos(null);
return;
}
updateDropdownPos();
window.addEventListener('scroll', updateDropdownPos, true);
window.addEventListener('resize', updateDropdownPos);
return () => {
window.removeEventListener('scroll', updateDropdownPos, true);
window.removeEventListener('resize', updateDropdownPos);
};
}, [isOpen, updateDropdownPos]);
// 외부 클릭 시 드롭다운 닫기 (Portal 영역 포함)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
const target = event.target as Node;
if (
containerRef.current && !containerRef.current.contains(target) &&
(!dropdownRef.current || !dropdownRef.current.contains(target))
) {
setIsOpen(false);
}
};
@@ -190,13 +271,34 @@ export const Select: React.FC<SelectProps> = ({
</Svg>
</Button>
{isOpen && (
{isOpen && dropdownPos && createPortal(
<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"
ref={dropdownRef}
className="fixed z-[9999] bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 overflow-hidden"
style={{ top: dropdownPos.top, bottom: dropdownPos.bottom, left: dropdownPos.left, width: dropdownPos.width }}
role="listbox"
>
{searchable && (
<Div className="p-2 border-b border-gray-200 dark:border-gray-600">
<Input
ref={searchInputRef}
type="text"
value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
placeholder={searchPlaceholder ?? 'Search...'}
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 border-0 rounded-lg text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
role="searchbox"
aria-label={searchPlaceholder ?? 'Search'}
/>
</Div>
)}
<Div className="py-2 max-h-60 overflow-auto">
{normalizedOptions.map((option) => {
{visibleOptions && visibleOptions.length === 0 && (
<Div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 text-center">
{searchTerm ? 'No results' : ''}
</Div>
)}
{(visibleOptions ?? []).map((option) => {
const isSelected = String(option.value) === String(value);
return (
<Button
@@ -225,7 +327,8 @@ export const Select: React.FC<SelectProps> = ({
);
})}
</Div>
</Div>
</Div>,
document.body
)}
</Div>
);
@@ -53,7 +53,7 @@ describe('FileInput 컴포넌트', () => {
fireEvent.change(fileInput);
expect(handleChange).toHaveBeenCalledWith(file);
expect(handleChange).toHaveBeenCalledWith({ target: { value: file, name: 'test.zip' } });
});
it('파일 선택 시 파일명이 표시된다', () => {
@@ -114,7 +114,7 @@ describe('FileInput 컴포넌트', () => {
fireEvent.change(fileInput);
expect(handleError).not.toHaveBeenCalled();
expect(handleChange).toHaveBeenCalledWith(file);
expect(handleChange).toHaveBeenCalledWith({ target: { value: file, name: 'small.zip' } });
});
});
@@ -147,12 +147,12 @@ describe('FileInput 컴포넌트', () => {
});
fireEvent.change(fileInput);
expect(handleChange).toHaveBeenCalledWith(file);
expect(handleChange).toHaveBeenCalledWith({ target: { value: file, name: 'test.zip' } });
const clearButton = screen.getByLabelText('Clear file');
fireEvent.click(clearButton);
expect(handleChange).toHaveBeenLastCalledWith(null);
expect(handleChange).toHaveBeenLastCalledWith({ target: { value: null, name: '' } });
expect(screen.getByText('No file selected')).toBeTruthy();
});
});
@@ -216,7 +216,7 @@ describe('FileInput 컴포넌트', () => {
fireEvent.change(fileInput);
expect(handleChange).toHaveBeenCalledWith(null);
expect(handleChange).toHaveBeenCalledWith({ target: { value: null, name: '' } });
});
});
});
@@ -614,4 +614,124 @@ describe('Select 컴포넌트', () => {
expect(select).toBeTruthy();
});
});
// ========================================
// searchable 모드 (engine-v1.40.0+)
// ========================================
describe('searchable 모드', () => {
const timezoneOptions = [
{ value: 'Asia/Seoul', label: '(UTC+09:00) Asia/Seoul' },
{ value: 'Asia/Tokyo', label: '(UTC+09:00) Asia/Tokyo' },
{ value: 'America/New_York', label: '(UTC-05:00) America/New_York' },
{ value: 'Europe/London', label: '(UTC+00:00) Europe/London' },
{ value: 'Pacific/Auckland', label: '(UTC+13:00) Pacific/Auckland' },
];
it('searchable=false 기본값에서는 검색 input이 렌더링되지 않는다', () => {
render(<Select options={timezoneOptions} value="Asia/Seoul" onChange={() => {}} />);
fireEvent.click(screen.getByRole('button'));
expect(screen.queryByRole('searchbox')).toBeNull();
});
it('searchable=true 시 드롭다운 내 검색 input이 렌더링된다', () => {
render(<Select options={timezoneOptions} value="Asia/Seoul" onChange={() => {}} searchable />);
fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('searchbox')).toBeTruthy();
});
it('searchPlaceholder가 input placeholder로 적용된다', () => {
render(
<Select
options={timezoneOptions}
value="Asia/Seoul"
onChange={() => {}}
searchable
searchPlaceholder="타임존 검색..."
/>
);
fireEvent.click(screen.getByRole('button'));
const input = screen.getByRole('searchbox') as HTMLInputElement;
expect(input.placeholder).toBe('타임존 검색...');
});
it('검색어 입력 시 라벨 기준으로 옵션이 필터링된다', () => {
render(<Select options={timezoneOptions} value="Asia/Seoul" onChange={() => {}} searchable />);
fireEvent.click(screen.getByRole('button'));
const input = screen.getByRole('searchbox');
fireEvent.change(input, { target: { value: 'tokyo' } });
const visibleOptions = screen.getAllByRole('option');
expect(visibleOptions.length).toBe(1);
expect(visibleOptions[0].textContent).toContain('Asia/Tokyo');
});
it('검색은 대소문자를 구분하지 않는다', () => {
render(<Select options={timezoneOptions} value="Asia/Seoul" onChange={() => {}} searchable />);
fireEvent.click(screen.getByRole('button'));
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'LONDON' } });
const visibleOptions = screen.getAllByRole('option');
expect(visibleOptions.length).toBe(1);
expect(visibleOptions[0].textContent).toContain('Europe/London');
});
it('value 문자열에 대해서도 검색된다', () => {
render(<Select options={timezoneOptions} value="Asia/Seoul" onChange={() => {}} searchable />);
fireEvent.click(screen.getByRole('button'));
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'pacific' } });
const visibleOptions = screen.getAllByRole('option');
expect(visibleOptions.length).toBe(1);
expect(visibleOptions[0].textContent).toContain('Pacific/Auckland');
});
it('검색 결과가 없으면 "No results" 메시지가 표시된다', () => {
render(<Select options={timezoneOptions} value="Asia/Seoul" onChange={() => {}} searchable />);
fireEvent.click(screen.getByRole('button'));
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'nonexistent-zzz' } });
expect(screen.queryAllByRole('option').length).toBe(0);
expect(screen.getByText('No results')).toBeTruthy();
});
it('필터된 옵션 선택 시 onChange가 호출되고 드롭다운이 닫힌다', () => {
const handleChange = vi.fn();
render(<Select options={timezoneOptions} value="Asia/Seoul" onChange={handleChange} searchable />);
fireEvent.click(screen.getByRole('button'));
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'tokyo' } });
const tokyoOption = screen.getAllByRole('option')[0];
fireEvent.click(tokyoOption);
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({ target: { value: 'Asia/Tokyo' } })
);
expect(screen.queryByRole('listbox')).toBeNull();
});
it('드롭다운을 다시 열면 검색어가 초기화된다', () => {
render(<Select options={timezoneOptions} value="Asia/Seoul" onChange={() => {}} searchable />);
// 첫 번째 열기 + 필터링
fireEvent.click(screen.getByRole('button'));
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'tokyo' } });
expect(screen.getAllByRole('option').length).toBe(1);
// 닫기 (외부 클릭 대신 ESC)
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
// 다시 열기 — 검색어가 초기화되어 모든 옵션이 보여야 함
fireEvent.click(screen.getByRole('button'));
expect(screen.getAllByRole('option').length).toBe(timezoneOptions.length);
const input = screen.getByRole('searchbox') as HTMLInputElement;
expect(input.value).toBe('');
});
});
});