// 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, 'onChange'> { label?: string; error?: string; options?: SelectOption[] | string[]; onChange?: (e: React.ChangeEvent | { 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; } /** * 로케일 코드를 사람이 읽을 수 있는 이름으로 변환 */ function getLocaleName(locale: string): string { const fromAppConfig = (window as any).G7Core?.state?.get?.('_global.appConfig.localeNames')?.[locale]; if (fromAppConfig) { return fromAppConfig; } const fallback: Record = { 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 = ({ 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(null); const buttonRef = useRef(null); const searchInputRef = useRef(null); const dropdownRef = useRef(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 ( ); } // 기본 스타일 (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; return (
{isOpen && dropdownPos && createPortal(
{searchable && (
) => 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'} />
)}
{visibleOptions && visibleOptions.length === 0 && (
{searchTerm ? 'No results' : ''}
)} {(visibleOptions ?? []).map((option) => { const isSelected = String(option.value) === String(value); return ( ); })}
, document.body )}
); };