v7.0.0-beta.2 release
This commit is contained in:
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>
|
||||
);
|
||||
|
||||
+5
-5
@@ -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: '' } });
|
||||
});
|
||||
});
|
||||
});
|
||||
+120
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user