diff --git a/CLAUDE.md b/CLAUDE.md index 714d969a..f2d36e19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,6 +138,17 @@ const response = await fetch('/api/proxy/item-master/init'); | 폼 | 레이아웃, 필드 배치, 버튼 위치 | | 테이블/리스트 | 컬럼 구조, 체크박스, 페이지네이션 | +### 컴포넌트 레지스트리 활용 (dev/component-registry) +실시간 스캔 기반 컴포넌트 목록 + 관계도 페이지가 존재함. 새로고침 시 최신 상태 반영. + +**새 컴포넌트 생성 전 필수 확인**: +1. **목록 뷰**: 동일/유사 컴포넌트가 이미 있는지 검색 +2. **관계도 뷰**: 유사 컴포넌트의 구성요소(imports)를 확인하여 동일한 공통 컴포넌트 조합 패턴 따르기 + +**기존 컴포넌트 수정 시 필수 확인**: +- 관계도의 **사용처(usedBy)** 확인 → 수정 시 영향받는 범위 파악 +- usedBy가 많은 공통 컴포넌트일수록 수정 시 주의 + --- ## Common Table Standards diff --git a/src/app/[locale]/(protected)/dev/component-registry/ComponentRegistryClient.tsx b/src/app/[locale]/(protected)/dev/component-registry/ComponentRegistryClient.tsx index 0a773fa6..74132872 100644 --- a/src/app/[locale]/(protected)/dev/component-registry/ComponentRegistryClient.tsx +++ b/src/app/[locale]/(protected)/dev/component-registry/ComponentRegistryClient.tsx @@ -13,9 +13,12 @@ import { Eye, Loader2, X, + List, + GitFork, } from 'lucide-react'; import { getComponentSource, type RegistryData, type ComponentEntry } from './actions'; import { UI_PREVIEWS } from './previews'; +import ComponentRelationshipView from './ComponentRelationshipView'; interface ComponentRegistryClientProps { registry: RegistryData; @@ -330,6 +333,7 @@ function CategorySection({ } export default function ComponentRegistryClient({ registry }: ComponentRegistryClientProps) { + const [viewMode, setViewMode] = useState<'list' | 'relationship'>('list'); const [searchTerm, setSearchTerm] = useState(''); const [activeTier, setActiveTier] = useState('전체'); const [activeDomainCategory, setActiveDomainCategory] = useState('전체'); @@ -435,7 +439,7 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC return (
-
+
{/* Header */}
@@ -447,131 +451,167 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC {registry.totalCount}개
-

- 스캔: {generatedDate} · 새로고침으로 갱신 (실시간 스캔) - · 카드 클릭: 소스코드 보기 · 프리뷰: {previewCount}개 -

-
- - {/* Search */} -
- - setSearchTerm(e.target.value)} - className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> - {searchTerm && ( - - )} -
- - {/* Tier Filter Chips */} -
- {ALL_TIERS.map((tier) => { - const isActive = activeTier === tier; - const count = tierCounts[tier] || 0; - if (tier !== '전체' && count === 0) return null; - - return ( +
+

+ 스캔: {generatedDate} · 새로고침으로 갱신 (실시간 스캔) + · 카드 클릭: 소스코드 보기 · 프리뷰: {previewCount}개 +

+
- ); - })} -
- - {/* Domain Sub-filter Chips */} - {activeTier === 'domain' && domainCategories.length > 0 && ( -
- - {domainCategories.map(({ name, count }) => ( - ))} +
+
+ + {/* Relationship View */} + {viewMode === 'relationship' && ( + )} - {/* Results count & Expand/Collapse All */} -
- {(searchTerm || activeTier !== '전체') ? ( -

- {filtered.length}개 결과 -

- ) :
} - {groupedByCategory.length > 1 && ( - - )} -
+ {/* List View */} + {viewMode === 'list' && ( + <> + {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {searchTerm && ( + + )} +
- {/* Component List */} -
- {groupedByCategory.map(([category, { tier, components }]) => ( - - ))} -
+ {/* Tier Filter Chips */} +
+ {ALL_TIERS.map((tier) => { + const isActive = activeTier === tier; + const count = tierCounts[tier] || 0; + if (tier !== '전체' && count === 0) return null; - {/* Empty State */} - {filtered.length === 0 && ( -
- -

검색 결과가 없습니다.

-
+ return ( + + ); + })} +
+ + {/* Domain Sub-filter Chips */} + {activeTier === 'domain' && domainCategories.length > 0 && ( +
+ + {domainCategories.map(({ name, count }) => ( + + ))} +
+ )} + + {/* Results count & Expand/Collapse All */} +
+ {(searchTerm || activeTier !== '전체') ? ( +

+ {filtered.length}개 결과 +

+ ) :
} + {groupedByCategory.length > 1 && ( + + )} +
+ + {/* Component List */} +
+ {groupedByCategory.map(([category, { tier, components }]) => ( + + ))} +
+ + {/* Empty State */} + {filtered.length === 0 && ( +
+ +

검색 결과가 없습니다.

+
+ )} + )} {/* Footer */} diff --git a/src/app/[locale]/(protected)/dev/component-registry/ComponentRelationshipView.tsx b/src/app/[locale]/(protected)/dev/component-registry/ComponentRelationshipView.tsx new file mode 100644 index 00000000..0846ea5a --- /dev/null +++ b/src/app/[locale]/(protected)/dev/component-registry/ComponentRelationshipView.tsx @@ -0,0 +1,431 @@ +'use client'; + +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { Search, X, ArrowRight, ArrowLeft, ChevronRight, ChevronLeft } from 'lucide-react'; +import type { ComponentEntry } from './actions'; + +const TIER_CONFIG: Record = { + ui: { label: 'UI', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200', ring: 'ring-blue-200' }, + atoms: { label: 'Atoms', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200', ring: 'ring-green-200' }, + molecules: { label: 'Molecules', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200', ring: 'ring-purple-200' }, + organisms: { label: 'Organisms', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200', ring: 'ring-orange-200' }, + common: { label: 'Common', color: 'text-teal-700', bg: 'bg-teal-50', border: 'border-teal-200', ring: 'ring-teal-200' }, + layout: { label: 'Layout', color: 'text-indigo-700', bg: 'bg-indigo-50', border: 'border-indigo-200', ring: 'ring-indigo-200' }, + dev: { label: 'Dev', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200', ring: 'ring-yellow-200' }, + domain: { label: 'Domain', color: 'text-gray-700', bg: 'bg-gray-50', border: 'border-gray-200', ring: 'ring-gray-200' }, +}; + +interface ComponentRelationshipViewProps { + components: ComponentEntry[]; +} + +/** 카드형 플로우: 사용처(좌) → 선택 컴포넌트(중앙) → 구성요소(우) */ +function FlowCard({ + comp, + onSelect, + size = 'normal', +}: { + comp: ComponentEntry; + onSelect: (filePath: string) => void; + size?: 'normal' | 'large'; +}) { + const tc = TIER_CONFIG[comp.tier] || TIER_CONFIG.domain; + const isLarge = size === 'large'; + + return ( + + ); +} + +/** 연결선 칼럼 (좌→중앙 또는 중앙→우 사이의 화살표 영역) */ +function ConnectorColumn({ direction, count }: { direction: 'left' | 'right'; count: number }) { + if (count === 0) return
; + + return ( +
+ {direction === 'left' ? ( + + ) : ( + + )} +
+ ); +} + +/** 2차 카드 (클릭하면 그 컴포넌트의 imports/usedBy를 미니 리스트로 펼침) */ +function ExpandableFlowCard({ + comp, + componentMap, + direction, + onSelect, +}: { + comp: ComponentEntry; + componentMap: Map; + direction: 'usedBy' | 'imports'; + onSelect: (filePath: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const tc = TIER_CONFIG[comp.tier] || TIER_CONFIG.domain; + const children = direction === 'usedBy' ? comp.usedBy : comp.imports; + const resolvedChildren = children + .map((fp) => componentMap.get(fp)) + .filter((c): c is ComponentEntry => c !== undefined); + const hasChildren = resolvedChildren.length > 0; + + return ( +
+
+ {/* 펼침 토글 */} + {hasChildren ? ( + + ) : ( +
+ )} + + {/* 메인 컨텐츠 (클릭 → 선택) */} + + + {/* 카운트 뱃지 */} + {hasChildren && ( + + {resolvedChildren.length} + + )} +
+ + {/* 펼친 하위 목록 */} + {expanded && hasChildren && ( +
+ {resolvedChildren.slice(0, 10).map((child) => { + const childTc = TIER_CONFIG[child.tier] || TIER_CONFIG.domain; + return ( + + ); + })} + {resolvedChildren.length > 10 && ( +

+{resolvedChildren.length - 10}개 더...

+ )} +
+ )} +
+ ); +} + +export default function ComponentRelationshipView({ components }: ComponentRelationshipViewProps) { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedFilePath, setSelectedFilePath] = useState(null); + const [showDropdown, setShowDropdown] = useState(false); + const [history, setHistory] = useState([]); + const dropdownRef = useRef(null); + + const componentMap = useMemo(() => { + const map = new Map(); + for (const comp of components) { + map.set(comp.filePath, comp); + } + return map; + }, [components]); + + const searchResults = useMemo(() => { + if (!searchTerm) return []; + const q = searchTerm.toLowerCase(); + return components + .filter( + (c) => + c.name.toLowerCase().includes(q) || + c.fileName.toLowerCase().includes(q) || + c.filePath.toLowerCase().includes(q) + ) + .slice(0, 10); + }, [components, searchTerm]); + + const selectedComponent = selectedFilePath ? componentMap.get(selectedFilePath) : null; + + const handleSelect = useCallback((filePath: string) => { + setSelectedFilePath((prev) => { + if (prev && prev !== filePath) { + setHistory((h) => [...h.slice(-19), prev]); + } + return filePath; + }); + setSearchTerm(''); + setShowDropdown(false); + }, []); + + const handleBack = useCallback(() => { + if (history.length === 0) return; + const prev = history[history.length - 1]; + setHistory((h) => h.slice(0, -1)); + setSelectedFilePath(prev); + }, [history]); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // resolve usedBy/imports 컴포넌트 목록 + const usedByComponents = useMemo(() => { + if (!selectedComponent) return []; + return selectedComponent.usedBy + .map((fp) => componentMap.get(fp)) + .filter((c): c is ComponentEntry => c !== undefined); + }, [selectedComponent, componentMap]); + + const importComponents = useMemo(() => { + if (!selectedComponent) return []; + return selectedComponent.imports + .map((fp) => componentMap.get(fp)) + .filter((c): c is ComponentEntry => c !== undefined); + }, [selectedComponent, componentMap]); + + return ( +
+ {/* 검색 입력 */} +
+ + { + setSearchTerm(e.target.value); + setShowDropdown(true); + }} + onFocus={() => { if (searchTerm) setShowDropdown(true); }} + className="w-full pl-10 pr-10 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {searchTerm && ( + + )} + + {showDropdown && searchResults.length > 0 && ( +
+ {searchResults.map((comp) => { + const tc = TIER_CONFIG[comp.tier] || TIER_CONFIG.domain; + return ( + + ); + })} +
+ )} +
+ + {/* 선택 전 안내 */} + {!selectedComponent && ( +
+
+
+ 사용처 +
+ +
+ 선택 +
+ +
+ 구성요소 +
+
+

컴포넌트를 검색하고 선택하면 관계도가 표시됩니다.

+

카드를 클릭하면 해당 컴포넌트로 이동합니다.

+
+ )} + + {/* 카드형 플로우 */} + {selectedComponent && ( + <> + {/* 뒤로가기 */} + {history.length > 0 && ( + + )} + + {/* 3칼럼 플로우 레이아웃 */} +
+ {/* 좌: 사용처 (이 컴포넌트를 사용하는 곳) */} +
+
+
+ + 사용처 + + + ({usedByComponents.length}) + +
+ {usedByComponents.length === 0 ? ( +
+

사용처 없음

+

최상위 컴포넌트

+
+ ) : ( +
+ {usedByComponents.map((comp) => ( + + ))} +
+ )} +
+ + {/* 연결선 좌→중앙 */} + + + {/* 중앙: 선택된 컴포넌트 */} +
+
+
+ + 선택됨 + +
+ +
+ + {/* 연결선 중앙→우 */} + + + {/* 우: 구성요소 (이 컴포넌트가 사용하는 것) */} +
+
+
+ + 구성요소 + + + ({importComponents.length}) + +
+ {importComponents.length === 0 ? ( +
+

구성요소 없음

+

기본 컴포넌트 (leaf)

+
+ ) : ( +
+ {importComponents.map((comp) => ( + + ))} +
+ )} +
+
+ + {/* 범례 */} +
+
+
+ 사용처: 이 컴포넌트를 import하는 곳 +
+
+
+ 구성요소: 이 컴포넌트가 import하는 것 +
+
+ 카드 클릭 = 해당 컴포넌트로 이동 +
+
+ + )} +
+ ); +} diff --git a/src/app/[locale]/(protected)/dev/component-registry/RelationshipTreeNode.tsx b/src/app/[locale]/(protected)/dev/component-registry/RelationshipTreeNode.tsx new file mode 100644 index 00000000..45972a07 --- /dev/null +++ b/src/app/[locale]/(protected)/dev/component-registry/RelationshipTreeNode.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { memo, useState } from 'react'; +import { ChevronRight, ChevronDown } from 'lucide-react'; +import type { ComponentEntry } from './actions'; + +const TIER_CONFIG: Record = { + ui: { label: 'UI', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' }, + atoms: { label: 'Atoms', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' }, + molecules: { label: 'Molecules', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' }, + organisms: { label: 'Organisms', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' }, + common: { label: 'Common', color: 'text-teal-700', bg: 'bg-teal-50', border: 'border-teal-200' }, + layout: { label: 'Layout', color: 'text-indigo-700', bg: 'bg-indigo-50', border: 'border-indigo-200' }, + dev: { label: 'Dev', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' }, + domain: { label: 'Domain', color: 'text-gray-700', bg: 'bg-gray-50', border: 'border-gray-200' }, +}; + +interface RelationshipTreeNodeProps { + component: ComponentEntry; + allComponents: Map; + direction: 'up' | 'down'; // up=usedBy, down=imports + depth: number; + maxDepth: number; + visitedPaths: Set; + onSelectComponent: (filePath: string) => void; +} + +export const RelationshipTreeNode = memo(function RelationshipTreeNode({ + component, + allComponents, + direction, + depth, + maxDepth, + visitedPaths, + onSelectComponent, +}: RelationshipTreeNodeProps) { + const childPaths = direction === 'up' ? component.usedBy : component.imports; + const children = childPaths + .map((fp) => allComponents.get(fp)) + .filter((c): c is ComponentEntry => c !== undefined); + const hasChildren = children.length > 0; + + const [expanded, setExpanded] = useState(depth < 2); + + const isCircular = visitedPaths.has(component.filePath); + const isMaxDepth = depth >= maxDepth; + + const tierConfig = TIER_CONFIG[component.tier] || TIER_CONFIG.domain; + const paddingLeft = depth * 24; + + return ( + <> +
onSelectComponent(component.filePath)} + > + {/* 펼침/접힘 */} + + + {/* 컴포넌트 정보 */} + + {component.name} + + + {tierConfig.label} + + + {isCircular && ( + (순환) + )} +
+ + {/* 자식 노드 (재귀) */} + {hasChildren && expanded && !isCircular && !isMaxDepth && ( + <> + {children.map((child) => { + const nextVisited = new Set(visitedPaths); + nextVisited.add(component.filePath); + return ( + + ); + })} + + )} + + ); +}); diff --git a/src/app/[locale]/(protected)/dev/component-registry/actions.ts b/src/app/[locale]/(protected)/dev/component-registry/actions.ts index 4e490ca8..7aea9d86 100644 --- a/src/app/[locale]/(protected)/dev/component-registry/actions.ts +++ b/src/app/[locale]/(protected)/dev/component-registry/actions.ts @@ -37,6 +37,8 @@ export interface ComponentEntry { propsName: string | null; isClientComponent: boolean; lineCount: number; + imports: string[]; // 이 컴포넌트가 사용하는 다른 컴포넌트 filePath[] + usedBy: string[]; // 이 컴포넌트를 사용하는 컴포넌트 filePath[] } interface CategorySummary { @@ -96,6 +98,16 @@ function getSubcategory(relPath: string): string | null { return parts.length > 2 ? parts[1] : null; } +function extractComponentImports(content: string): string[] { + const regex = /from\s+['"]@\/components\/([^'"]+)['"]/g; + const imports: string[] = []; + let match; + while ((match = regex.exec(content)) !== null) { + imports.push(match[1]); // "ui/button", "organisms/DataTable" 등 + } + return imports; +} + function extractComponentInfo(content: string, fileName: string) { const isClient = /^['"]use client['"];?/m.test(content); @@ -143,8 +155,9 @@ function extractComponentInfo(content: string, fileName: string) { const propsName = propsNameMatch ? propsNameMatch[1] : null; const lineCount = content.split('\n').length; + const componentImports = extractComponentImports(content); - return { name, exportType, hasProps, propsName, isClientComponent: isClient, lineCount }; + return { name, exportType, hasProps, propsName, isClientComponent: isClient, lineCount, componentImports }; } async function scanDirectory(dir: string, componentsRoot: string): Promise { @@ -175,6 +188,8 @@ async function scanDirectory(dir: string, componentsRoot: string): Promise(); + + for (const comp of components) { + // "src/components/ui/button.tsx" → 여러 매칭 키 등록 + const rel = comp.filePath.replace(/^src\/components\//, ''); // "ui/button.tsx" + const withoutExt = rel.replace(/\.tsx$/, ''); // "ui/button" + + pathLookup.set(withoutExt, comp.filePath); + pathLookup.set(rel, comp.filePath); + + // 디렉토리/파일명 패턴: "organisms/DataTable/DataTable" → filePath + const parts = withoutExt.split('/'); + if (parts.length >= 2) { + const dirName = parts[parts.length - 2]; + const fileName = parts[parts.length - 1]; + // "organisms/DataTable" → filePath (barrel 패턴 대응) + if (dirName === fileName) { + const barrelPath = parts.slice(0, -1).join('/'); + if (!pathLookup.has(barrelPath)) { + pathLookup.set(barrelPath, comp.filePath); + } + } + } + } + + // 각 컴포넌트의 raw imports → resolved filePath + for (const comp of components) { + const resolved: string[] = []; + for (const rawImport of comp.imports) { + // rawImport: "ui/button", "organisms/DataTable/DataTable" 등 + const found = pathLookup.get(rawImport); + if (found && found !== comp.filePath) { + resolved.push(found); + } + } + comp.imports = [...new Set(resolved)]; + } + + // 역방향: usedBy 구축 + const usedByMap = new Map>(); + for (const comp of components) { + for (const imp of comp.imports) { + if (!usedByMap.has(imp)) usedByMap.set(imp, new Set()); + usedByMap.get(imp)!.add(comp.filePath); + } + } + for (const comp of components) { + const set = usedByMap.get(comp.filePath); + comp.usedBy = set ? [...set] : []; + } +} + /** * 컴포넌트 레지스트리 실시간 스캔 * @@ -206,6 +278,7 @@ export async function scanComponentRegistry(): Promise { const componentsRoot = join(process.cwd(), 'src', 'components'); const components = await scanDirectory(componentsRoot, componentsRoot); + buildRelationships(components); components.sort((a, b) => a.tier.localeCompare(b.tier) || a.category.localeCompare(b.category) || a.name.localeCompare(b.name) ); diff --git a/src/app/[locale]/(protected)/dev/component-registry/previews.tsx b/src/app/[locale]/(protected)/dev/component-registry/previews.tsx index 6e13786a..cb955ffe 100644 --- a/src/app/[locale]/(protected)/dev/component-registry/previews.tsx +++ b/src/app/[locale]/(protected)/dev/component-registry/previews.tsx @@ -80,6 +80,22 @@ import { PageHeader } from '@/components/organisms/PageHeader'; import { StatCards } from '@/components/organisms/StatCards'; import { MobileCard } from '@/components/organisms/MobileCard'; import { FormFieldGrid } from '@/components/organisms/FormFieldGrid'; +// Organisms - 추가 +import { SearchFilter } from '@/components/organisms/SearchFilter'; +import { ScreenVersionHistory } from '@/components/organisms/ScreenVersionHistory'; +import { PageLayout } from '@/components/organisms/PageLayout'; +// Common +import { EmptyPage } from '@/components/common/EmptyPage'; +import { ServerErrorPage } from '@/components/common/ServerErrorPage'; +import { AccessDenied } from '@/components/common/AccessDenied'; +import { EditableTable } from '@/components/common/EditableTable/EditableTable'; +import { NoticePopupModal } from '@/components/common/NoticePopupModal/NoticePopupModal'; +import { ScheduleCalendar } from '@/components/common/ScheduleCalendar/ScheduleCalendar'; +// Organisms - 추가 +import { DataTable } from '@/components/organisms/DataTable'; +import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal'; +// UI - 추가 +import { VisuallyHidden } from '@/components/ui/visually-hidden'; // Lucide icons for demos import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react'; @@ -224,6 +240,15 @@ function YearQuarterFilterDemo() { return ; } +function SearchFilterDemo() { + const [v, setV] = useState(''); + return ( +
+ +
+ ); +} + function MobileFilterDemo() { const [values, setValues] = useState>({ status: 'all' }); const fields = [ @@ -236,6 +261,84 @@ function MobileFilterDemo() { return setValues(prev => ({ ...prev, [k]: v }))} onReset={() => setValues({ status: 'all' })} />; } +function EditableTableDemo() { + const [data, setData] = useState([ + { id: '1', name: '볼트 M10', spec: 'M10x30', qty: 100 }, + { id: '2', name: '너트 M10', spec: 'M10', qty: 200 }, + ]); + return ( + ({ id: String(Date.now()), name: '', spec: '', qty: 0 })} + compact + maxRows={5} + /> + ); +} + +function NoticePopupDemo() { + const [open, setOpen] = useState(false); + return ( + <> + + 2026년 2월 15일 (토) 02:00~06:00 시스템 점검이 예정되어 있습니다.

', + }} + /> + + ); +} + +function SearchableSelectionDemo() { + const [open, setOpen] = useState(false); + const mockItems = [ + { id: '1', name: '볼트 M10', code: 'BLT-001' }, + { id: '2', name: '너트 M10', code: 'NUT-001' }, + { id: '3', name: '와셔 M10', code: 'WSH-001' }, + { id: '4', name: '스프링 와셔', code: 'SWS-001' }, + ]; + return ( + <> + + { + await new Promise(r => setTimeout(r, 300)); + if (!query) return mockItems; + return mockItems.filter(item => + item.name.includes(query) || item.code.includes(query) + ); + }} + keyExtractor={(item) => item.id} + renderItem={(item) => ( +
+
{item.name}
+
{item.code}
+
+ )} + onSelect={() => setOpen(false)} + loadOnOpen + /> + + ); +} + // ── Preview Registry ── type PreviewEntry = { @@ -1188,4 +1291,153 @@ export const UI_PREVIEWS: Record = { ), }, ], + + // ─── Organisms 추가 ─── + 'SearchFilter.tsx': [ + { label: 'Search', render: () => }, + ], + + 'ScreenVersionHistory.tsx': [ + { + label: 'History', + render: () => ( +
+ +
+ ), + }, + ], + + 'PageLayout.tsx': [ + { + label: 'Max Widths', + render: () => ( +
+ +
maxWidth="sm" (max-w-3xl)
+
+ +
maxWidth="full" (기본값)
+
+
+ ), + }, + ], + + // ─── Common ─── + 'EmptyPage.tsx': [ + { + label: 'Under Construction', + render: () => ( +
+ +
+ ), + }, + ], + + 'ServerErrorPage.tsx': [ + { + label: 'Server Error', + render: () => ( +
+ {}} /> +
+ ), + }, + ], + + 'AccessDenied.tsx': [ + { + label: 'Access Denied', + render: () => ( +
+ +
+ ), + }, + ], + + 'EditableTable.tsx': [ + { label: 'Editable', render: () => }, + ], + + 'NoticePopupModal.tsx': [ + { label: 'Popup', render: () => }, + ], + + 'ScheduleCalendar.tsx': [ + { + label: 'Calendar', + render: () => ( +
+ +
+ ), + }, + ], + + // ─── UI 추가 ─── + 'visually-hidden.tsx': [ + { + label: 'Concept', + render: () => ( +
+

스크린리더 전용 숨김 텍스트:

+
+ + 아이콘만 표시, 접근성 텍스트 포함 +
+
+ ), + }, + ], + + // ─── Organisms 추가 ─── + 'DataTable.tsx': [ + { + label: 'Mock Table', + render: () => ( +
+ +
+ ), + }, + ], + + 'SearchableSelectionModal.tsx': [ + { label: 'Modal', render: () => }, + ], };