v7.0.0 release

This commit is contained in:
HeuJung committed 2026-07-01 10:30:32 +09:00
1 parent 0e7fa04b03
commit c4ea9a6cd1
3582 files changed
+626189 -160151

No files matched your search

@@ -1,3 +1,4 @@
// e2e:allow편집기 passthrough 결함#2 수정(admin 동기, data-editor-*/id 를 시각적 루트 Div 로 라우팅). 단위 회귀는 FileInput.test.tsx passthrough describe.
import React, { forwardRef, useRef, useCallback, useState } from 'react';
import { Div } from './Div';
import { Span } from './Span';
@@ -40,6 +41,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 className="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();
@@ -81,7 +96,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"
@@ -89,7 +104,7 @@ export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(({
onChange={handleChange as any}
disabled={disabled}
className="hidden"
{...props}
{...inputProps}
/>
<Button
type="button"
@@ -320,8 +320,22 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
// HTML 표준 속성이 아닌 것들을 필터링 (타입 안전성 개선)
const { loadingactions, formdata, ...validProps } = props as typeof props & NonStandardProps;
// 레이아웃 편집기 표식/핸들러(data-editor-*, onMouseMove/Leave 등)는 시각적
// 루트(wrapper div)에 부착해야 캔버스 선택/드래그가 컴포넌트 전체를 잡는다.
// 내부 input 에만 spread 하면 선택 박스가 input 으로 한정되고 error/검증/토글이
// 선택 영역 밖이 된다.
const editorRootProps: Record<string, unknown> = {};
const inputRestProps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(validProps as Record<string, unknown>)) {
if (k.startsWith('data-editor-') || k === 'onMouseMove' || k === 'onMouseLeave') {
editorRootProps[k] = v;
} else {
inputRestProps[k] = v;
}
}
return (
<div className={`relative ${wrapperClassName}`}>
<div className={`relative ${wrapperClassName}`} {...editorRootProps}>
{/* 입력 필드 wrapper */}
<div className="relative">
<input
@@ -334,7 +348,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
onKeyDown={handleKeyEvent}
onKeyUp={handleKeyEvent}
disabled={disabled}
{...validProps}
{...inputRestProps}
/>
{/* 비밀번호 보기/숨기기 버튼 */}
@@ -1,3 +1,4 @@
// e2e:allow커스텀 드롭다운 루트 props spread 타입 안전 캐스팅(admin 동기). 단위 회귀는 Select.test.tsx.
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);
@@ -237,6 +245,7 @@ export const Select: React.FC<SelectProps> = ({
value={value}
onChange={onChange as React.ChangeEventHandler<HTMLSelectElement>}
disabled={disabled}
{...editorAttrs}
{...props}
>
{children}
@@ -252,8 +261,13 @@ export const Select: React.FC<SelectProps> = ({
? `${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}`;
// 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"
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { FileInput } from '../FileInput';
describe('FileInput 컴포넌트 (sirsoft-basic)', () => {
// ========================================
// 레이아웃 편집기 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"
/>
);
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');
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');
});
});
});
@@ -431,4 +431,49 @@ describe('PasswordInput 컴포넌트', () => {
expect(screen.getByTestId('icon-triangle-exclamation')).toBeInTheDocument();
});
});
// 레이아웃 편집기 표식(data-editor-*)은 내부 input 이 아니라
// 시각적 루트(wrapper div)에 부착되어야 캔버스 선택/드래그가 컴포넌트 전체를 잡는다.
describe('레이아웃 편집기 표식 부착 위치', () => {
it('data-editor-* / onMouseMove 는 wrapper 루트 div 에 부착', () => {
const onMouseMove = vi.fn();
const { container } = render(
<PasswordInput
wrapperClassName="vpw-root"
{...({
'data-editor-id': 'auto_PW_1',
'data-editor-name': 'PasswordInput',
'data-editor-path': '0.children.1',
onMouseMove,
} as Record<string, unknown>)}
/>
);
// 루트 div 가 표식을 가져야 함
const root = container.firstElementChild as HTMLElement;
expect(root.tagName).toBe('DIV');
expect(root).toHaveClass('vpw-root');
expect(root.getAttribute('data-editor-id')).toBe('auto_PW_1');
expect(root.getAttribute('data-editor-name')).toBe('PasswordInput');
expect(root.getAttribute('data-editor-path')).toBe('0.children.1');
// 내부 input 에는 data-editor-* 가 없어야 함 (루트로만 흘러야 함)
const input = container.querySelector('input') as HTMLElement;
expect(input.getAttribute('data-editor-path')).toBeNull();
expect(input.getAttribute('data-editor-id')).toBeNull();
});
it('일반 input 속성(name/placeholder)은 내부 input 에 그대로 전달', () => {
const { container } = render(
<PasswordInput
name="user_password"
placeholder="비밀번호 입력"
{...({ 'data-editor-name': 'PasswordInput' } as Record<string, unknown>)}
/>
);
const input = container.querySelector('input') as HTMLInputElement;
expect(input.getAttribute('name')).toBe('user_password');
expect(input.getAttribute('placeholder')).toBe('비밀번호 입력');
// 편집기 표식은 input 에 누출되지 않음
expect(input.getAttribute('data-editor-name')).toBeNull();
});
});
});
@@ -1,3 +1,4 @@
// e2e:allow 편집기 ComponentRegistry 등록 정합(basic Header/Footer export 제거 — composite 와 이름 충돌 해소, SlotContainer 번들 포함). 편집기 캔버스 렌더는 Chrome MCP 라이브 실측 + Vitest(headerEditorSpecAndCurrencySlot/HeaderPropRender)로 검증. 사용자 사이트 런타임 무변경.
/**
* 기본 HTML 매핑 컴포넌트 Export
*
@@ -38,8 +39,10 @@ export { Section, type SectionProps } from './Section';
export { Svg, type SvgProps } from './Svg';
export { Icon, type IconProps } from './Icon';
export { Code, type CodeProps } from './Code';
export { Footer, type FooterProps } from './Footer';
export { Header, type HeaderProps } from './Header';
// basic Header/Footer(HTML <header>/<footer> 래퍼)는 레이아웃 사용처 0이며 composite
// Header/Footer 와 이름이 겹쳐 편집기 ComponentRegistry 에서 중복 등록 경고를 유발한다.
// 사이트 헤더/푸터는 composite 컴포넌트가 담당하므로 basic 래퍼 export 를 제거한다
// (필요 시 Div + role/시맨틱 태그로 대체). 파일 자체는 보존(직접 import 경로는 유지).
export { Hr, type HrProps } from './Hr';
// Icon 타입 정의 (enum 및 타입)