Files
Gnuboard7/templates/_bundled/sirsoft-admin_basic/src/components/basic/Select.tsx
T
2026-07-01 10:30:32 +09:00

366 lines
14 KiB
TypeScript

// 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';
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')) ?? {
log: (...args: unknown[]) => console.log('[Comp:Select]', ...args),
warn: (...args: unknown[]) => console.warn('[Comp:Select]', ...args),
error: (...args: unknown[]) => console.error('[Comp:Select]', ...args),
};
export interface SelectOption {
value: string | number;
label: string;
disabled?: boolean;
}
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
label?: string;
error?: string;
options?: SelectOption[] | string[];
onChange?: (e: React.ChangeEvent<HTMLSelectElement> | { target: { value: string | number } }) => void;
/** 드롭다운 내 검색 input 활성화 (engine-v1.40.0+) */
searchable?: boolean;
/** 검색 input placeholder */
searchPlaceholder?: string;
/**
* 레이아웃 편집기 주입 속성 (편집 모드 전용). Select 는 커스텀 드롭다운 루트에
* 개별 data-editor-* 키를 spread 해야 선택/편집이 닿는다(커스텀 루트
* passthrough). basic 컴포넌트라 `{...props}` 로 DOM 표준 속성(id 등)도 함께 흐른다.
*/
editorAttrs?: Record<string, unknown>;
}
/**
* 로케일 코드를 사람이 읽을 수 있는 이름으로 변환
*/
function getLocaleName(locale: string): string {
const fromAppConfig = (window as any).G7Core?.state?.get?.('_global.appConfig.localeNames')?.[locale];
if (fromAppConfig) {
return fromAppConfig;
}
const fallback: Record<string, string> = {
ko: '한국어',
en: 'English',
ja: '日本語',
zh: '中文',
es: 'Español',
fr: 'Français',
de: 'Deutsch',
};
return fallback[locale] || locale;
}
/**
* 커스텀 Select 컴포넌트
*
* options prop을 사용하여 드롭다운 메뉴를 생성합니다.
* 둥근 모서리, 그림자, 체크마크가 있는 커스텀 스타일을 지원합니다.
*/
export const Select: React.FC<SelectProps> = ({
children,
label,
error,
options,
className = '',
value,
onChange,
disabled,
searchable = false,
searchPlaceholder,
editorAttrs,
...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 => {
if (!options) return null;
// 배열이 아닌 경우 (바인딩 에러 또는 잘못된 prop)
if (!Array.isArray(options)) {
logger.warn('options prop is not an array:', options);
return null;
}
// 빈 배열인 경우
if (options.length === 0) return [];
// string[] 배열인 경우
if (typeof options[0] === 'string') {
return (options as string[]).map((locale): SelectOption => ({
value: locale,
label: getLocaleName(locale),
}));
}
// SelectOption[] 배열인 경우 (기존 동작)
return options as SelectOption[];
}, [options]);
// 현재 선택된 옵션의 라벨 가져오기
const selectedLabel = useMemo(() => {
if (!normalizedOptions) return '';
const selected = normalizedOptions.find(opt => String(opt.value) === String(value));
return selected?.label || '';
}, [normalizedOptions, value]);
// 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) => {
const target = event.target as Node;
if (
containerRef.current && !containerRef.current.contains(target) &&
(!dropdownRef.current || !dropdownRef.current.contains(target))
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// ESC 키로 드롭다운 닫기
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
setIsOpen(false);
buttonRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
const handleToggle = () => {
if (!disabled) {
setIsOpen(!isOpen);
}
};
const handleSelect = (optionValue: string | number) => {
if (onChange) {
// ActionDispatcher가 이벤트를 인식할 수 있도록 preventDefault 메서드 포함
const syntheticEvent = {
target: { value: optionValue },
preventDefault: () => {},
stopPropagation: () => {},
type: 'change',
};
onChange(syntheticEvent as any);
}
setIsOpen(false);
buttonRef.current?.focus();
};
// options가 없으면 기본 select 사용 (children 지원)
// .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={mergedClassName}
value={value}
onChange={onChange as React.ChangeEventHandler<HTMLSelectElement>}
disabled={disabled}
{...editorAttrs}
{...props}
>
{children}
</select>
);
}
// 기본 스타일 (className이 없을 때 적용)
// 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 = /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-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" {...rootDomProps}>
<Button
ref={buttonRef}
type="button"
onClick={handleToggle}
disabled={disabled}
className={`${baseButtonClass} flex items-center justify-between gap-2 text-left cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed`}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<Span className="truncate">{selectedLabel || '\u00A0'}</Span>
<Svg
className={`w-4 h-4 flex-shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</Svg>
</Button>
{isOpen && dropdownPos && createPortal(
<Div
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">
{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
key={option.value}
type="button"
onClick={() => !option.disabled && handleSelect(option.value)}
disabled={option.disabled}
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"
aria-selected={isSelected}
>
<Span>{option.label}</Span>
{isSelected && (
<Svg
className="w-5 h-5 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</Svg>
)}
</Button>
);
})}
</Div>
</Div>,
document.body
)}
</Div>
);
};