Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import { PictureOutlined } from '@ant-design/icons';
import type { ImgHTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
type InlineImageProps = ImgHTMLAttributes<HTMLImageElement> & {
fallbackText?: string;
};
export function InlineImage({ alt, className, fallbackText, onError, src, ...rest }: InlineImageProps) {
const [hasError, setHasError] = useState(!src);
useEffect(() => {
setHasError(!src);
}, [src]);
if (hasError) {
return (
<div
className={['inline-image-fallback', className].filter(Boolean).join(' ')}
role="img"
aria-label={alt || fallbackText || '이미지를 불러오지 못했습니다.'}
>
<PictureOutlined />
<span>{fallbackText || '이미지를 불러오지 못했습니다.'}</span>
</div>
);
}
return (
<img
{...rest}
alt={alt}
className={className}
src={src}
onError={(event) => {
setHasError(true);
onError?.(event);
}}
/>
);
}

View File

@@ -0,0 +1,51 @@
import { Flex, Typography } from 'antd';
const { Text } = Typography;
export type MultiProgressItem = {
label: string;
percent: number;
color: string;
};
export type MultiProgressUIProps = {
label: string;
meta?: string;
data: MultiProgressItem[];
};
export function MultiProgressUI({ label, meta, data }: MultiProgressUIProps) {
return (
<Flex vertical gap={12} className="dashboard-multi-progress-ui">
<div className="dashboard-multi-progress-ui__header">
<div className="dashboard-multi-progress-ui__copy">
<Text className="dashboard-multi-progress-ui__label">{label}</Text>
{meta ? <Text type="secondary">{meta}</Text> : null}
</div>
</div>
<div className="dashboard-multi-progress-ui__bar">
{data.map((item) => (
<div
key={item.label}
className="dashboard-multi-progress-ui__segment"
style={{ width: `${item.percent}%`, backgroundColor: item.color }}
/>
))}
</div>
<Flex vertical gap={8}>
{data.map((item) => (
<div key={item.label} className="dashboard-multi-progress-ui__legend">
<span
className="dashboard-multi-progress-ui__swatch"
style={{ backgroundColor: item.color }}
/>
<Text>{item.label}</Text>
<Text className="dashboard-multi-progress-ui__percent">{item.percent}%</Text>
</div>
))}
</Flex>
</Flex>
);
}

View File

@@ -0,0 +1,2 @@
export { MultiProgressUI } from './MultiProgressUI';
export type { MultiProgressItem, MultiProgressUIProps } from './MultiProgressUI';

View File

@@ -0,0 +1 @@
export { createMultiProgressMetaPlugin, createMultiProgressSortPlugin } from './multi-progress.plugin';

View File

@@ -0,0 +1,16 @@
import type { PropsPlugin } from '../../../../types/component-plugin';
import type { MultiProgressUIProps } from '../types';
export function createMultiProgressMetaPlugin(meta: string): PropsPlugin<MultiProgressUIProps> {
return (props) => ({
...props,
meta,
});
}
export function createMultiProgressSortPlugin(): PropsPlugin<MultiProgressUIProps> {
return (props) => ({
...props,
data: [...props.data].sort((left, right) => right.percent - left.percent),
});
}

View File

@@ -0,0 +1,28 @@
import type { SampleMeta } from '../../../../widgets/core';
import { MultiProgressUI } from '../MultiProgressUI';
export const sampleMeta: SampleMeta = {
id: 'dashboard-multi-progress-base',
componentId: 'dashboard-multi-progress',
title: 'Dashboard Multi Progress',
description: '여러 단계 진행률을 하나의 막대와 범례로 함께 보여주는 컴포넌트입니다.',
category: 'Components',
kind: 'base',
variantLabel: 'Base',
order: 11,
features: ['docs'],
};
export function Sample() {
return (
<MultiProgressUI
label="오늘 처리 현황"
meta="입고 / 피킹 / 출고"
data={[
{ label: '입고', percent: 28, color: '#165dff' },
{ label: '피킹', percent: 34, color: '#52c41a' },
{ label: '출고', percent: 38, color: '#fa8c16' },
]}
/>
);
}

View File

@@ -0,0 +1,36 @@
import type { SampleMeta } from '../../../../widgets/core';
import { plugins } from '../../../../types/component-plugin';
import { MultiProgressUI } from '../MultiProgressUI';
import {
createMultiProgressMetaPlugin,
createMultiProgressSortPlugin,
} from '../plugins';
import type { MultiProgressUIProps } from '../types';
export const sampleMeta: SampleMeta = {
id: 'dashboard-multi-progress',
componentId: 'dashboard-multi-progress',
title: 'Dashboard Multi Progress',
description: '여러 상태 비중을 하나의 bar와 범례로 표현하는 대시보드 컴포넌트입니다.',
category: 'Components',
kind: 'feature',
variantLabel: 'Showcase',
order: 11,
features: ['docs'],
};
export function Sample() {
const props = plugins<MultiProgressUIProps>(
{
label: '배송 상태 비중',
data: [
{ label: '배송 완료', percent: 54, color: '#28c76f' },
{ label: '배송 중', percent: 28, color: '#00cfe8' },
{ label: '지연', percent: 18, color: '#ea5455' },
],
},
[createMultiProgressMetaPlugin('오늘 기준'), createMultiProgressSortPlugin()],
);
return <MultiProgressUI {...props} />;
}

View File

@@ -0,0 +1 @@
export type { MultiProgressItem, MultiProgressUIProps } from './multi-progress';

View File

@@ -0,0 +1,11 @@
export type MultiProgressItem = {
label: string;
percent: number;
color: string;
};
export type MultiProgressUIProps = {
label: string;
meta?: string;
data: MultiProgressItem[];
};

View File

@@ -0,0 +1,26 @@
import { Flex, Progress, Typography } from 'antd';
import type { ProgressUIProps } from './types';
const { Text } = Typography;
export function ProgressUI({ label, meta, data }: ProgressUIProps) {
return (
<Flex vertical gap={8} className="dashboard-progress-ui">
<div className="dashboard-progress-ui__header">
<div className="dashboard-progress-ui__copy">
<Text className="dashboard-progress-ui__label">{label}</Text>
{meta ? <Text type="secondary">{meta}</Text> : null}
</div>
<Text className="dashboard-progress-ui__percent">{data.percent}%</Text>
</div>
<Progress
percent={data.percent}
size="small"
showInfo={false}
strokeColor={data.color}
className="dashboard-progress-ui__bar"
/>
</Flex>
);
}

View File

@@ -0,0 +1,3 @@
export { ProgressUI } from './ProgressUI';
export { createProgressColorPlugin, createProgressMetaPlugin } from './plugins';
export type { ProgressUIData, ProgressUIProps } from './types';

View File

@@ -0,0 +1 @@
export { createProgressColorPlugin, createProgressMetaPlugin } from './progress.plugin';

View File

@@ -0,0 +1,19 @@
import type { PropsPlugin } from '../../../../types/component-plugin';
import type { ProgressUIProps } from '../types';
export function createProgressMetaPlugin(meta: string): PropsPlugin<ProgressUIProps> {
return (props) => ({
...props,
meta,
});
}
export function createProgressColorPlugin(color: string): PropsPlugin<ProgressUIProps> {
return (props) => ({
...props,
data: {
...props.data,
color,
},
});
}

View File

@@ -0,0 +1,27 @@
import type { SampleMeta } from '../../../../widgets/core';
import { ProgressUI } from '../ProgressUI';
export const sampleMeta: SampleMeta = {
id: 'dashboard-progress-base',
componentId: 'dashboard-progress',
title: 'Dashboard Progress',
description: '라벨, 메타, 진행률을 함께 표현하는 대시보드 progress 컴포넌트입니다.',
category: 'Components',
kind: 'base',
variantLabel: 'Base',
order: 10,
features: ['docs'],
};
export function Sample() {
return (
<ProgressUI
label="배송 완료"
meta="오늘 기준"
data={{
percent: 72,
color: '#165dff',
}}
/>
);
}

View File

@@ -0,0 +1,32 @@
import type { SampleMeta } from '../../../../widgets/core';
import { plugins } from '../../../../types/component-plugin';
import { ProgressUI } from '../ProgressUI';
import { createProgressColorPlugin, createProgressMetaPlugin } from '../plugins';
import type { ProgressUIProps } from '../types';
export const sampleMeta: SampleMeta = {
id: 'dashboard-progress',
componentId: 'dashboard-progress',
title: 'Dashboard Progress',
description: '라벨, 메타, 진행률을 함께 표현하는 대시보드 progress 컴포넌트입니다.',
category: 'Components',
kind: 'feature',
variantLabel: 'Showcase',
order: 10,
features: ['docs'],
};
export function Sample() {
const props = plugins<ProgressUIProps>(
{
label: '배송 완료',
data: {
percent: 72,
color: '#28c76f',
},
},
[createProgressMetaPlugin('오늘 기준'), createProgressColorPlugin('#165dff')],
);
return <ProgressUI {...props} />;
}

View File

@@ -0,0 +1 @@
export type { ProgressUIData, ProgressUIProps } from './progress';

View File

@@ -0,0 +1,10 @@
export type ProgressUIData = {
percent: number;
color: string;
};
export type ProgressUIProps = {
label: string;
meta?: string;
data: ProgressUIData;
};

View File

@@ -0,0 +1,42 @@
.data-list-table {
width: 100%;
}
.data-list-table__toolbar {
width: 100%;
}
.data-list-table__search {
width: min(100%, 320px);
}
.data-list-table__filter {
min-width: 160px;
}
.data-list-table__mobile {
display: none;
}
.data-list-table__card {
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 8px;
background: #fff;
}
@media (max-width: 720px) {
.data-list-table__desktop--with-mobile {
display: none;
}
.data-list-table__mobile {
display: grid;
gap: 12px;
}
.data-list-table__search,
.data-list-table__filter {
width: 100%;
}
}

View File

@@ -0,0 +1,204 @@
import { Input, Pagination, Select, Space, Table, Typography } from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { EmptyState, ErrorState, LoadingState } from '../stateKit';
import './DataListTable.css';
const { Text } = Typography;
export type DataListFilterOption<T> = {
value: string;
label: ReactNode;
predicate: (item: T) => boolean;
};
export type DataListTableProps<T extends object> = {
data: T[];
columns: ColumnsType<T>;
getRowKey: (item: T) => React.Key;
searchPlaceholder?: string;
searchFields?: ReadonlyArray<keyof T | ((item: T) => string)>;
searchPredicate?: (item: T, keyword: string) => boolean;
filters?: ReadonlyArray<DataListFilterOption<T>>;
pageSize?: number;
loading?: boolean;
error?: ReactNode;
emptyTitle?: ReactNode;
emptyDescription?: ReactNode;
retryLabel?: ReactNode;
onRetry?: () => void;
mobileCardRender?: (item: T) => ReactNode;
tableProps?: Omit<
TableProps<T>,
'columns' | 'dataSource' | 'rowKey' | 'pagination' | 'loading'
>;
};
function normalizeSearchText(text: string) {
return text.trim().toLowerCase();
}
function resolveFieldText<T extends object>(
item: T,
field: keyof T | ((item: T) => string),
) {
if (typeof field === 'function') {
return field(item);
}
const value = item[field];
if (value === null || value === undefined) {
return '';
}
return String(value);
}
function filterBySearch<T extends object>(
items: T[],
keyword: string,
searchFields?: DataListTableProps<T>['searchFields'],
searchPredicate?: DataListTableProps<T>['searchPredicate'],
) {
const normalizedKeyword = normalizeSearchText(keyword);
if (!normalizedKeyword) {
return items;
}
if (searchPredicate) {
return items.filter((item) => searchPredicate(item, normalizedKeyword));
}
if (!searchFields?.length) {
return items;
}
return items.filter((item) =>
searchFields.some((field) =>
normalizeSearchText(resolveFieldText(item, field)).includes(normalizedKeyword),
),
);
}
export function DataListTable<T extends object>({
data,
columns,
getRowKey,
searchPlaceholder = '검색어를 입력하세요',
searchFields,
searchPredicate,
filters = [],
pageSize = 10,
loading,
error,
emptyTitle = '표시할 항목이 없습니다.',
emptyDescription,
retryLabel,
onRetry,
mobileCardRender,
tableProps,
}: DataListTableProps<T>) {
const [keyword, setKeyword] = useState('');
const [activeFilter, setActiveFilter] = useState<string | undefined>();
const [currentPage, setCurrentPage] = useState(1);
const filteredData = useMemo(() => {
const searchedItems = filterBySearch(data, keyword, searchFields, searchPredicate);
const filter = filters.find((item) => item.value === activeFilter);
return filter ? searchedItems.filter(filter.predicate) : searchedItems;
}, [activeFilter, data, filters, keyword, searchFields, searchPredicate]);
const pagedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return filteredData.slice(startIndex, startIndex + pageSize);
}, [currentPage, filteredData, pageSize]);
const pageCount = Math.max(1, Math.ceil(filteredData.length / pageSize));
useEffect(() => {
setCurrentPage((previousPage) => Math.min(previousPage, pageCount));
}, [pageCount]);
if (error) {
return <ErrorState message={error} retryLabel={retryLabel} onRetry={onRetry} />;
}
if (loading) {
return <LoadingState variant="list" rows={Math.min(pageSize, 5)} />;
}
return (
<Space direction="vertical" size={16} className="data-list-table">
<Space wrap className="data-list-table__toolbar">
<Input.Search
allowClear
value={keyword}
placeholder={searchPlaceholder}
className="data-list-table__search"
onChange={(event) => {
setKeyword(event.target.value);
setCurrentPage(1);
}}
/>
{filters.length ? (
<Select
allowClear
value={activeFilter}
placeholder="필터"
className="data-list-table__filter"
options={filters.map((filter) => ({
value: filter.value,
label: filter.label,
}))}
onChange={(nextFilter) => {
setActiveFilter(nextFilter);
setCurrentPage(1);
}}
/>
) : null}
<Text type="secondary">{filteredData.length}</Text>
</Space>
{filteredData.length ? (
<>
<Table<T>
{...tableProps}
className={[
'data-list-table__desktop',
mobileCardRender ? 'data-list-table__desktop--with-mobile' : '',
tableProps?.className ?? '',
]
.filter(Boolean)
.join(' ')}
columns={columns}
dataSource={pagedData}
rowKey={getRowKey}
pagination={false}
/>
{mobileCardRender ? (
<div className="data-list-table__mobile">
{pagedData.map((item) => (
<article key={getRowKey(item)} className="data-list-table__card">
{mobileCardRender(item)}
</article>
))}
</div>
) : null}
{filteredData.length > pageSize ? (
<Pagination
current={currentPage}
pageSize={pageSize}
total={filteredData.length}
onChange={setCurrentPage}
/>
) : null}
</>
) : (
<EmptyState title={emptyTitle} description={emptyDescription} />
)}
</Space>
);
}

View File

@@ -0,0 +1 @@
export * from './DataListTable';

View File

@@ -0,0 +1,97 @@
import { Space, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { SampleMeta } from '../../../widgets/core';
import { StatusBadgeUI } from '../../status-badge';
import { DataListTable } from '../DataListTable';
const { Text } = Typography;
type BoardItem = {
id: string;
title: string;
owner: string;
status: 'ready' | 'working' | 'done';
progress: number;
};
const data: BoardItem[] = [
{ id: 'board-1', title: '컴포넌트 구성 추가 제안', owner: 'Board', status: 'working', progress: 45 },
{ id: 'plan-1', title: 'Plan 목록 정리', owner: 'Plan', status: 'ready', progress: 15 },
{ id: 'docs-1', title: 'Docs preview 연결', owner: 'Docs', status: 'done', progress: 100 },
];
const columns: ColumnsType<BoardItem> = [
{
title: '제목',
dataIndex: 'title',
sorter: (left, right) => left.title.localeCompare(right.title),
},
{
title: '담당',
dataIndex: 'owner',
filters: Array.from(new Set(data.map((item) => item.owner))).map((owner) => ({
text: owner,
value: owner,
})),
onFilter: (value, record) => record.owner === value,
},
{
title: '상태',
dataIndex: 'status',
render: (status: BoardItem['status']) => (
<StatusBadgeUI
label={status}
tone={status === 'done' ? 'success' : status === 'working' ? 'processing' : 'default'}
/>
),
},
{
title: '진행률',
dataIndex: 'progress',
sorter: (left, right) => left.progress - right.progress,
render: (progress: number) => `${progress}%`,
},
];
export const sampleMeta: SampleMeta = {
id: 'data-list-table-base',
componentId: 'data-list-table',
title: 'Data List Table',
description: '검색, 필터, 정렬, 페이지네이션과 모바일 카드 전환을 함께 제공하는 목록형 화면 컴포넌트입니다.',
category: 'Common',
kind: 'base',
variantLabel: 'Base',
order: 40,
features: ['docs', 'component-sample'],
};
export function Sample() {
return (
<DataListTable
data={data}
columns={columns}
getRowKey={(item) => item.id}
searchFields={['title', 'owner', 'status']}
searchPlaceholder="제목, 담당, 상태 검색"
pageSize={2}
filters={[
{
value: 'active',
label: '진행 중',
predicate: (item) => item.status !== 'done',
},
]}
mobileCardRender={(item) => (
<Space direction="vertical" size={6}>
<Text strong>{item.title}</Text>
<Text type="secondary">{item.owner}</Text>
<StatusBadgeUI
label={item.status}
tone={item.status === 'done' ? 'success' : item.status === 'working' ? 'processing' : 'default'}
/>
<Text>{item.progress}%</Text>
</Space>
)}
/>
);
}

View File

@@ -0,0 +1,162 @@
.data-state-panel.ant-card {
width: 100%;
min-width: 0;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgba(125, 211, 252, 0.18), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%);
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.68);
}
.data-state-panel .ant-card-body {
padding: 22px;
}
.data-state-panel__inner {
min-width: 0;
}
.data-state-panel__hero {
display: flex;
align-items: flex-start;
gap: 14px;
min-width: 0;
}
.data-state-panel__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
0 10px 24px rgba(15, 23, 42, 0.08);
color: #0f172a;
font-size: 20px;
flex-shrink: 0;
}
.data-state-panel__copy {
min-width: 0;
flex: 1 1 auto;
}
.data-state-panel__title.ant-typography,
.data-state-panel__title--compact.ant-typography {
margin: 0;
}
.data-state-panel__description.ant-typography {
margin: 0;
line-height: 1.6;
word-break: break-word;
}
.data-state-panel__content {
min-width: 0;
}
.data-state-panel__actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.data-state-panel__skeleton .ant-skeleton {
width: 100%;
}
.data-state-panel--compact {
border-radius: 18px;
}
.data-state-panel--compact .ant-card-body {
padding: 16px;
}
.data-state-panel--compact .data-state-panel__hero {
gap: 12px;
}
.data-state-panel--compact .data-state-panel__icon {
width: 38px;
height: 38px;
border-radius: 12px;
font-size: 16px;
}
.data-state-panel--loading {
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(241, 245, 249, 0.96) 100%);
}
.data-state-panel--loading .data-state-panel__icon {
color: #2563eb;
}
.data-state-panel--empty {
background:
radial-gradient(circle at top left, rgba(226, 232, 240, 0.56), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%);
}
.data-state-panel--empty .data-state-panel__icon {
color: #475569;
}
.data-state-panel--error {
background:
radial-gradient(circle at top left, rgba(254, 202, 202, 0.48), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(254, 242, 242, 0.94) 100%);
}
.data-state-panel--error .data-state-panel__icon {
color: #dc2626;
}
.data-state-panel--ready {
background:
radial-gradient(circle at top left, rgba(187, 247, 208, 0.46), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(240, 253, 244, 0.94) 100%);
}
.data-state-panel--ready .data-state-panel__icon {
color: #16a34a;
}
@media (max-width: 768px) {
.data-state-panel.ant-card {
border-radius: 20px;
}
.data-state-panel .ant-card-body {
padding: 18px;
}
.data-state-panel__hero {
gap: 12px;
}
.data-state-panel__icon {
width: 40px;
height: 40px;
border-radius: 14px;
font-size: 18px;
}
.data-state-panel__actions {
width: 100%;
}
.data-state-panel__actions .ant-btn {
flex: 1 1 140px;
}
}

View File

@@ -0,0 +1,158 @@
import {
CheckCircleOutlined,
ExclamationCircleOutlined,
InboxOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { Card, Flex, Skeleton, Typography } from 'antd';
import type { CardProps } from 'antd';
import type { ReactNode } from 'react';
import './DataStatePanel.css';
const { Paragraph, Text, Title } = Typography;
export type DataStatePanelStatus = 'loading' | 'empty' | 'error' | 'ready';
export type DataStatePanelProps = {
state: DataStatePanelStatus;
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
children?: ReactNode;
compact?: boolean;
icon?: ReactNode;
loadingRows?: number;
className?: string;
cardProps?: Omit<CardProps, 'children' | 'className'>;
};
function getDefaultIcon(state: DataStatePanelStatus) {
switch (state) {
case 'loading':
return <LoadingOutlined />;
case 'empty':
return <InboxOutlined />;
case 'error':
return <ExclamationCircleOutlined />;
case 'ready':
return <CheckCircleOutlined />;
default:
return null;
}
}
function getDefaultTitle(state: DataStatePanelStatus) {
switch (state) {
case 'loading':
return '데이터를 불러오는 중입니다.';
case 'empty':
return '표시할 데이터가 없습니다.';
case 'error':
return '데이터를 불러오지 못했습니다.';
case 'ready':
return '데이터를 확인할 수 있습니다.';
default:
return undefined;
}
}
function getDefaultDescription(state: DataStatePanelStatus) {
switch (state) {
case 'loading':
return '응답을 정리하는 동안 패널 레이아웃을 유지합니다.';
case 'empty':
return '조건을 조정하거나 새 항목을 추가하면 이 영역이 채워집니다.';
case 'error':
return '네트워크 또는 권한 상태를 확인한 뒤 다시 시도하세요.';
case 'ready':
return undefined;
default:
return undefined;
}
}
function getSkeletonWidths(compact: boolean, rows: number) {
if (compact) {
return Array.from({ length: rows }, (_, index) => (index === rows - 1 ? '84%' : '100%'));
}
return Array.from({ length: rows }, (_, index) => {
if (index === rows - 1) {
return '74%';
}
return '100%';
});
}
function renderLoadingBody(compact: boolean, rows: number) {
return (
<div className="data-state-panel__skeleton">
<Skeleton
active
title={{ width: compact ? '52%' : '38%' }}
paragraph={{ rows, width: getSkeletonWidths(compact, rows) }}
/>
</div>
);
}
export function DataStatePanel({
state,
title,
description,
actions,
children,
compact = false,
icon,
loadingRows = compact ? 2 : 3,
className,
cardProps,
}: DataStatePanelProps) {
const resolvedTitle = title ?? getDefaultTitle(state);
const resolvedDescription = description ?? getDefaultDescription(state);
const panelClassName = [
'data-state-panel',
`data-state-panel--${state}`,
compact ? 'data-state-panel--compact' : '',
className ?? '',
]
.filter(Boolean)
.join(' ');
return (
<Card {...cardProps} className={panelClassName}>
<Flex vertical gap={compact ? 14 : 18} className="data-state-panel__inner">
<div className="data-state-panel__hero">
<span className="data-state-panel__icon" aria-hidden="true">
{icon ?? getDefaultIcon(state)}
</span>
<Flex vertical gap={4} className="data-state-panel__copy">
{resolvedTitle ? (
compact ? (
<Text strong className="data-state-panel__title data-state-panel__title--compact">
{resolvedTitle}
</Text>
) : (
<Title level={5} className="data-state-panel__title">
{resolvedTitle}
</Title>
)
) : null}
{resolvedDescription ? (
<Paragraph className="data-state-panel__description" type="secondary">
{resolvedDescription}
</Paragraph>
) : null}
</Flex>
</div>
{state === 'loading' ? renderLoadingBody(compact, loadingRows) : children ? (
<div className="data-state-panel__content">{children}</div>
) : null}
{actions ? <div className="data-state-panel__actions">{actions}</div> : null}
</Flex>
</Card>
);
}

View File

@@ -0,0 +1,2 @@
export { DataStatePanel } from './DataStatePanel';
export type { DataStatePanelProps, DataStatePanelStatus } from './DataStatePanel';

View File

@@ -0,0 +1,26 @@
.data-state-panel-sample-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 12px;
}
.data-state-panel-sample-mobile {
margin-top: 12px;
}
.data-state-panel-sample-mobile__frame {
width: min(100%, 360px);
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 28px;
background:
linear-gradient(180deg, rgba(226, 232, 240, 0.48) 0%, rgba(248, 250, 252, 0.9) 100%);
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.08);
}
@media (max-width: 900px) {
.data-state-panel-sample-grid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,131 @@
import { Button, Flex, Space, Tag, Typography } from 'antd';
import type { SampleMeta } from '../../../widgets/core';
import { DataStatePanel } from '../DataStatePanel';
import './BaseSample.css';
const { Paragraph, Text } = Typography;
export const sampleMeta: SampleMeta = {
id: 'data-state-panel-base',
componentId: 'data-state-panel',
title: 'Data State Panel',
description:
'loading, empty, error, ready 상태를 공통 카드로 표현하고 title, description, actions 슬롯과 compact 변형을 함께 제공하는 상태 패널입니다.',
category: 'Common',
kind: 'base',
variantLabel: 'Base',
order: 38,
features: ['docs', 'component-sample'],
};
function ReadyContent() {
return (
<Flex vertical gap={10}>
<Space wrap>
<Tag color="green">ready</Tag>
<Tag color="blue">12 records</Tag>
<Tag color="gold">synced 2m ago</Tag>
</Space>
<Paragraph style={{ marginBottom: 0 }}>
, , .
</Paragraph>
</Flex>
);
}
export function Sample() {
return (
<Flex vertical gap={24}>
<div>
<Text strong>Default States</Text>
<div className="data-state-panel-sample-grid">
<DataStatePanel
state="loading"
title="추천 데이터를 정리하고 있습니다."
description="패널 높이를 유지한 상태로 로딩 스켈레톤을 보여줍니다."
actions={<Button> </Button>}
/>
<DataStatePanel
state="empty"
title="추천 결과가 아직 없습니다."
description="필터를 완화하거나 수집 범위를 넓히면 추천 항목이 표시됩니다."
actions={
<>
<Button type="primary"> </Button>
<Button> </Button>
</>
}
/>
<DataStatePanel
state="error"
title="추천 결과를 가져오지 못했습니다."
description="API 응답이 지연되었거나 권한 검증에 실패했습니다."
actions={
<>
<Button type="primary" danger>
</Button>
<Button> </Button>
</>
}
/>
<DataStatePanel
state="ready"
title="추천 결과 12건"
description="상태가 ready면 children 슬롯에 실제 데이터 내용을 배치합니다."
actions={
<>
<Button type="primary"> </Button>
<Button>CSV </Button>
</>
}
>
<ReadyContent />
</DataStatePanel>
</div>
</div>
<div>
<Text strong>Compact + Mobile Sample</Text>
<div className="data-state-panel-sample-mobile">
<div className="data-state-panel-sample-mobile__frame">
<Flex vertical gap={12}>
<DataStatePanel
state="loading"
compact
title="모바일 패널 동기화 중"
description="작은 카드에서도 같은 정보 구조를 유지합니다."
/>
<DataStatePanel
state="ready"
compact
title="오늘의 추천 3건"
description="액션은 모바일에서 자동 줄바꿈됩니다."
actions={
<>
<Button type="primary" size="small">
</Button>
<Button size="small"></Button>
</>
}
>
<Space direction="vertical" size={6}>
<Text strong>Data State Panel</Text>
<Text type="secondary"> UI를 </Text>
</Space>
</DataStatePanel>
<DataStatePanel
state="empty"
compact
title="표시할 카드가 없습니다."
description="모바일 컬럼이나 보조 패널에 그대로 넣을 수 있습니다."
actions={<Button size="small"> </Button>}
/>
</Flex>
</div>
</div>
</div>
</Flex>
);
}

View File

@@ -0,0 +1,154 @@
.embedded-map-ui {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
min-width: 0;
}
.embedded-map-ui__header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.embedded-map-ui__copy {
min-width: 0;
}
.embedded-map-ui__copy .ant-typography {
margin-bottom: 0;
}
.embedded-map-ui__frame {
position: relative;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgba(14, 165, 233, 0.16), transparent 34%),
linear-gradient(180deg, rgba(248, 250, 252, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.embedded-map-ui__canvas {
display: block;
width: 100%;
height: 100%;
min-height: 240px;
border: 0;
background: #e2e8f0;
}
.embedded-map-ui__canvas--locked {
pointer-events: none;
}
.embedded-map-ui__overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.embedded-map-ui__overlay-svg {
width: 100%;
height: 100%;
}
.embedded-map-ui__radius {
fill: rgba(56, 189, 248, 0.18);
stroke: rgba(2, 132, 199, 0.92);
stroke-width: 0.45;
}
.embedded-map-ui__link {
stroke: rgba(249, 115, 22, 0.72);
stroke-width: 0.5;
stroke-dasharray: 1.2 1;
}
.embedded-map-ui__marker {
stroke: rgba(15, 23, 42, 0.9);
stroke-width: 0.5;
}
.embedded-map-ui__marker--primary {
fill: #0ea5e9;
}
.embedded-map-ui__marker--secondary {
fill: #f97316;
}
.embedded-map-ui__label {
position: absolute;
transform: translate(-50%, calc(-100% - 14px));
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14);
white-space: nowrap;
}
.embedded-map-ui__label--primary {
background: rgba(14, 165, 233, 0.96);
color: #082f49;
}
.embedded-map-ui__label--secondary {
background: rgba(249, 115, 22, 0.94);
color: #431407;
}
.embedded-map-ui__lock-badge {
position: absolute;
right: 14px;
bottom: 14px;
border-radius: 999px;
padding: 6px 10px;
background: rgba(15, 23, 42, 0.82);
color: #f8fafc;
font-size: 12px;
font-weight: 600;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
}
.embedded-map-ui__slot {
position: absolute;
inset: 0;
}
.embedded-map-ui__footer {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.embedded-map-ui__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@media (max-width: 768px) {
.embedded-map-ui__header,
.embedded-map-ui__footer {
flex-direction: column;
align-items: flex-start;
}
.embedded-map-ui__frame {
border-radius: 18px;
}
.embedded-map-ui__label {
max-width: 45vw;
overflow: hidden;
text-overflow: ellipsis;
}
}

View File

@@ -0,0 +1,247 @@
import type { ReactNode } from 'react';
import { EnvironmentOutlined, ExportOutlined } from '@ant-design/icons';
import { Button, Space, Tag, Typography } from 'antd';
import './EmbeddedMapUI.css';
const { Paragraph, Text, Title } = Typography;
export type EmbeddedMapUIProps = {
latitude: number;
longitude: number;
zoom?: number;
height?: number;
title?: string;
address?: string;
description?: string;
markerLabel?: string;
className?: string;
radiusMeters?: number;
lockViewport?: boolean;
secondaryMarker?: {
latitude: number;
longitude: number;
label?: string;
} | null;
overlay?: ReactNode;
};
type Bounds = {
minLatitude: number;
maxLatitude: number;
minLongitude: number;
maxLongitude: number;
};
function clampZoom(zoom: number) {
return Math.min(18, Math.max(8, zoom));
}
function metersToLatitudeDegrees(meters: number) {
return meters / 111320;
}
function metersToLongitudeDegrees(meters: number, latitude: number) {
const safeLatitude = Math.max(-89.9999, Math.min(89.9999, latitude));
const denominator = 111320 * Math.cos((safeLatitude * Math.PI) / 180);
return meters / Math.max(denominator, 0.000001);
}
function createViewUrl(latitude: number, longitude: number, zoom: number) {
const params = new URLSearchParams({
mlon: String(longitude),
mlat: String(latitude),
});
return `https://www.openstreetmap.org/?${params.toString()}#map=${clampZoom(zoom)}/${latitude}/${longitude}`;
}
function createBounds(
latitude: number,
longitude: number,
radiusMeters: number | undefined,
secondaryMarker: EmbeddedMapUIProps['secondaryMarker'],
): Bounds {
const coverageMeters = Math.max(radiusMeters ?? 0, secondaryMarker ? 180 : 120, 120) * 1.8;
const latitudePadding = metersToLatitudeDegrees(coverageMeters);
const longitudePadding = metersToLongitudeDegrees(coverageMeters, latitude);
let minLatitude = latitude - latitudePadding;
let maxLatitude = latitude + latitudePadding;
let minLongitude = longitude - longitudePadding;
let maxLongitude = longitude + longitudePadding;
if (secondaryMarker) {
const extraLatitudePadding = metersToLatitudeDegrees(coverageMeters * 0.35);
const extraLongitudePadding = metersToLongitudeDegrees(
coverageMeters * 0.35,
(latitude + secondaryMarker.latitude) / 2,
);
minLatitude = Math.min(minLatitude, secondaryMarker.latitude - extraLatitudePadding);
maxLatitude = Math.max(maxLatitude, secondaryMarker.latitude + extraLatitudePadding);
minLongitude = Math.min(minLongitude, secondaryMarker.longitude - extraLongitudePadding);
maxLongitude = Math.max(maxLongitude, secondaryMarker.longitude + extraLongitudePadding);
}
return {
minLatitude,
maxLatitude,
minLongitude,
maxLongitude,
};
}
function createEmbedUrl(bounds: Bounds, latitude: number, longitude: number) {
const params = new URLSearchParams({
bbox: [
bounds.minLongitude,
bounds.minLatitude,
bounds.maxLongitude,
bounds.maxLatitude,
].join(','),
layer: 'mapnik',
marker: `${latitude},${longitude}`,
});
return `https://www.openstreetmap.org/export/embed.html?${params.toString()}`;
}
function projectPoint(latitude: number, longitude: number, bounds: Bounds) {
const xRatio =
(longitude - bounds.minLongitude) / Math.max(bounds.maxLongitude - bounds.minLongitude, 0.000001);
const yRatio =
(bounds.maxLatitude - latitude) / Math.max(bounds.maxLatitude - bounds.minLatitude, 0.000001);
return {
x: Math.min(1, Math.max(0, xRatio)) * 100,
y: Math.min(1, Math.max(0, yRatio)) * 100,
};
}
function calculateCircleSizePercent(radiusMeters: number, bounds: Bounds, latitude: number) {
const diameterLongitudeDegrees = metersToLongitudeDegrees(radiusMeters * 2, latitude);
return (diameterLongitudeDegrees / Math.max(bounds.maxLongitude - bounds.minLongitude, 0.000001)) * 100;
}
export function EmbeddedMapUI({
latitude,
longitude,
zoom = 15,
height = 360,
title = '현장 위치',
address,
description = '모바일과 데스크톱에서 바로 확인할 수 있는 내장 지도입니다.',
markerLabel = '현장',
className,
radiusMeters,
lockViewport = false,
secondaryMarker,
overlay,
}: EmbeddedMapUIProps) {
const resolvedZoom = clampZoom(zoom);
const viewUrl = createViewUrl(latitude, longitude, resolvedZoom);
const bounds = createBounds(latitude, longitude, radiusMeters, secondaryMarker);
const embedUrl = createEmbedUrl(bounds, latitude, longitude);
const primaryPoint = projectPoint(latitude, longitude, bounds);
const secondaryPoint = secondaryMarker
? projectPoint(secondaryMarker.latitude, secondaryMarker.longitude, bounds)
: null;
const radiusPercent =
radiusMeters && radiusMeters > 0 ? calculateCircleSizePercent(radiusMeters, bounds, latitude) : null;
return (
<div className={['embedded-map-ui', className].filter(Boolean).join(' ')}>
<div className="embedded-map-ui__header">
<div className="embedded-map-ui__copy">
<Space size={8} wrap>
<Tag color="cyan" icon={<EnvironmentOutlined />}>
{markerLabel}
</Tag>
<Text type="secondary">{`${latitude.toFixed(5)}, ${longitude.toFixed(5)}`}</Text>
</Space>
<Title level={4}>{title}</Title>
<Paragraph>{description}</Paragraph>
{address ? <Text type="secondary">{address}</Text> : null}
</div>
<Button href={viewUrl} target="_blank" rel="noreferrer" icon={<ExportOutlined />}>
</Button>
</div>
<div className="embedded-map-ui__frame" style={{ height }}>
<iframe
className={[
'embedded-map-ui__canvas',
lockViewport ? 'embedded-map-ui__canvas--locked' : null,
]
.filter(Boolean)
.join(' ')}
src={embedUrl}
title={title}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
<div className="embedded-map-ui__overlay">
<svg
className="embedded-map-ui__overlay-svg"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
{radiusPercent ? (
<circle
className="embedded-map-ui__radius"
cx={primaryPoint.x}
cy={primaryPoint.y}
r={Math.max(2, radiusPercent / 2)}
/>
) : null}
{secondaryPoint ? (
<>
<line
className="embedded-map-ui__link"
x1={primaryPoint.x}
y1={primaryPoint.y}
x2={secondaryPoint.x}
y2={secondaryPoint.y}
/>
<circle className="embedded-map-ui__marker embedded-map-ui__marker--secondary" cx={secondaryPoint.x} cy={secondaryPoint.y} r="1.8" />
</>
) : null}
<circle className="embedded-map-ui__marker embedded-map-ui__marker--primary" cx={primaryPoint.x} cy={primaryPoint.y} r="2.2" />
</svg>
<div
className="embedded-map-ui__label embedded-map-ui__label--primary"
style={{ left: `${primaryPoint.x}%`, top: `${primaryPoint.y}%` }}
>
{markerLabel}
</div>
{secondaryPoint ? (
<div
className="embedded-map-ui__label embedded-map-ui__label--secondary"
style={{ left: `${secondaryPoint.x}%`, top: `${secondaryPoint.y}%` }}
>
{secondaryMarker?.label ?? '보조 위치'}
</div>
) : null}
{lockViewport ? (
<div className="embedded-map-ui__lock-badge"> </div>
) : null}
{overlay ? <div className="embedded-map-ui__slot">{overlay}</div> : null}
</div>
</div>
<div className="embedded-map-ui__footer">
<div className="embedded-map-ui__meta">
<Tag bordered={false}>Zoom {resolvedZoom}</Tag>
<Tag bordered={false}>Radius {radiusMeters ? `${Math.round(radiusMeters)}m` : 'Off'}</Tag>
<Tag bordered={false}>{lockViewport ? 'Viewport Locked' : 'Viewport Free'}</Tag>
<Tag bordered={false}>Embed Map</Tag>
</div>
<Text type="secondary">
OpenStreetMap . .
</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { EmbeddedMapUI } from './EmbeddedMapUI';
export type { EmbeddedMapUIProps } from './EmbeddedMapUI';

View File

@@ -0,0 +1,29 @@
import type { SampleMeta } from '../../../widgets/core';
import { EmbeddedMapUI } from '../EmbeddedMapUI';
export const sampleMeta: SampleMeta = {
id: 'embedded-map-ui-base',
componentId: 'embedded-map-ui',
title: 'Embedded Map UI',
description: '앱 내부에 지도를 직접 내장해 모바일과 데스크톱에서 같은 위치 정보를 바로 보여주는 컴포넌트입니다.',
category: 'Display',
kind: 'base',
variantLabel: 'Base',
order: 70,
features: ['docs'],
};
export function Sample() {
return (
<EmbeddedMapUI
title="서울역 출발 허브"
address="서울 중구 한강대로 405"
description="출발 허브와 주변 도로 상태를 앱 안에서 바로 확인합니다."
latitude={37.55472}
longitude={126.97083}
zoom={15}
height={320}
markerLabel="출발"
/>
);
}

View File

@@ -0,0 +1,52 @@
import { Card, Flex, Typography } from 'antd';
import type { SampleMeta } from '../../../widgets/core';
import { EmbeddedMapUI } from '../EmbeddedMapUI';
const { Paragraph, Text } = Typography;
export const sampleMeta: SampleMeta = {
id: 'embedded-map-ui',
componentId: 'embedded-map-ui',
title: 'Embedded Map UI',
description: '앱 내부에 지도를 직접 내장해 모바일과 데스크톱에서 같은 위치 정보를 바로 보여주는 컴포넌트입니다.',
category: 'Display',
kind: 'feature',
variantLabel: 'Showcase',
order: 70,
features: ['docs'],
};
export function Sample() {
return (
<Card title="Embedded Map UI Sample" extra={<Text code>components/embeddedMap</Text>}>
<Paragraph>
iframe .
, .
</Paragraph>
<Flex vertical gap={20}>
<EmbeddedMapUI
title="서울역 출발 허브"
address="서울 중구 한강대로 405"
description="출발 허브와 주변 도로 상태를 앱 안에서 바로 확인하는 예시입니다."
latitude={37.55472}
longitude={126.97083}
zoom={15}
height={320}
markerLabel="출발"
/>
<EmbeddedMapUI
title="모바일 배송지 확인"
address="서울 강남구 테헤란로 521"
description="상세 카드 안에서 바로 열어 기사나 운영자가 휴대폰에서도 위치를 확인할 수 있습니다."
latitude={37.50632}
longitude={127.05323}
zoom={16}
height={280}
markerLabel="도착"
/>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1,131 @@
.empty-illustration-card {
--empty-card-border: rgba(148, 163, 184, 0.22);
--empty-card-bg: linear-gradient(145deg, #fffdf8 0%, #fff4e4 55%, #eef5ff 100%);
--empty-card-shadow: 0 20px 50px rgba(15, 23, 42, 0.08);
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
gap: 24px;
align-items: center;
padding: 28px;
border: 1px solid var(--empty-card-border);
border-radius: 24px;
background: var(--empty-card-bg);
box-shadow: var(--empty-card-shadow);
overflow: hidden;
}
.empty-illustration-card--compact {
grid-template-columns: 132px minmax(0, 1fr);
gap: 18px;
padding: 20px;
border-radius: 18px;
}
.empty-illustration-card__visual {
display: flex;
justify-content: center;
}
.empty-illustration-card__art {
position: relative;
width: min(100%, 220px);
aspect-ratio: 1 / 1;
}
.empty-illustration-card__art-device {
position: absolute;
inset: 18% 20% 22%;
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.75);
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 247, 237, 0.92));
box-shadow: 0 18px 40px rgba(249, 115, 22, 0.12);
backdrop-filter: blur(6px);
}
.empty-illustration-card__art-line {
height: 10px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.28);
}
.empty-illustration-card__art-line--strong {
width: 72%;
background: linear-gradient(90deg, rgba(249, 115, 22, 0.9), rgba(251, 191, 36, 0.85));
}
.empty-illustration-card__art-line--short {
width: 48%;
}
.empty-illustration-card__art-orb,
.empty-illustration-card__art-ring {
position: absolute;
border-radius: 999px;
}
.empty-illustration-card__art-orb--orange {
top: 8%;
right: 10%;
width: 46px;
height: 46px;
background: radial-gradient(circle at 30% 30%, #fdba74, #f97316 68%);
box-shadow: 0 12px 24px rgba(249, 115, 22, 0.2);
}
.empty-illustration-card__art-orb--blue {
bottom: 10%;
left: 12%;
width: 34px;
height: 34px;
background: radial-gradient(circle at 30% 30%, #bfdbfe, #2563eb 72%);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.18);
}
.empty-illustration-card__art-ring {
inset: 4%;
border: 1px dashed rgba(59, 130, 246, 0.2);
}
.empty-illustration-card__content {
min-width: 0;
}
.empty-illustration-card__eyebrow.ant-typography {
margin: 0;
color: #c2410c;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.empty-illustration-card__title.ant-typography {
margin: 0;
color: #111827;
}
.empty-illustration-card__description.ant-typography {
margin: 0;
color: rgba(55, 65, 81, 0.82);
}
.empty-illustration-card__actions {
align-items: center;
}
@media (max-width: 768px) {
.empty-illustration-card,
.empty-illustration-card--compact {
grid-template-columns: 1fr;
gap: 16px;
padding: 18px;
}
.empty-illustration-card__art {
width: min(100%, 180px);
}
}

View File

@@ -0,0 +1,84 @@
import { Button, Flex, Typography } from 'antd';
import type { ButtonProps } from 'antd';
import type { ReactNode } from 'react';
import './EmptyIllustrationCard.css';
const { Paragraph, Text, Title } = Typography;
export type EmptyIllustrationCardVariant = 'default' | 'compact';
export type EmptyIllustrationCardProps = {
title: ReactNode;
description?: ReactNode;
ctaLabel?: ReactNode;
onCtaClick?: ButtonProps['onClick'];
ctaProps?: Omit<ButtonProps, 'children' | 'onClick'>;
variant?: EmptyIllustrationCardVariant;
illustration?: ReactNode;
eyebrow?: ReactNode;
extra?: ReactNode;
className?: string;
};
function DefaultIllustration() {
return (
<div className="empty-illustration-card__art" aria-hidden="true">
<div className="empty-illustration-card__art-device">
<span className="empty-illustration-card__art-line empty-illustration-card__art-line--strong" />
<span className="empty-illustration-card__art-line" />
<span className="empty-illustration-card__art-line empty-illustration-card__art-line--short" />
</div>
<div className="empty-illustration-card__art-orb empty-illustration-card__art-orb--orange" />
<div className="empty-illustration-card__art-orb empty-illustration-card__art-orb--blue" />
<div className="empty-illustration-card__art-ring" />
</div>
);
}
export function EmptyIllustrationCard({
title,
description,
ctaLabel,
onCtaClick,
ctaProps,
variant = 'default',
illustration,
eyebrow,
extra,
className,
}: EmptyIllustrationCardProps) {
const rootClassName = [
'empty-illustration-card',
`empty-illustration-card--${variant}`,
className ?? '',
]
.filter(Boolean)
.join(' ');
return (
<section className={rootClassName}>
<div className="empty-illustration-card__visual">{illustration ?? <DefaultIllustration />}</div>
<Flex vertical gap={variant === 'compact' ? 10 : 14} className="empty-illustration-card__content">
{eyebrow ? <Text className="empty-illustration-card__eyebrow">{eyebrow}</Text> : null}
<Flex vertical gap={6}>
<Title level={variant === 'compact' ? 5 : 4} className="empty-illustration-card__title">
{title}
</Title>
{description ? (
<Paragraph className="empty-illustration-card__description">{description}</Paragraph>
) : null}
</Flex>
{ctaLabel || extra ? (
<Flex gap={10} wrap="wrap" className="empty-illustration-card__actions">
{ctaLabel ? (
<Button type="primary" onClick={onCtaClick} {...ctaProps}>
{ctaLabel}
</Button>
) : null}
{extra}
</Flex>
) : null}
</Flex>
</section>
);
}

View File

@@ -0,0 +1 @@
export * from './EmptyIllustrationCard';

View File

@@ -0,0 +1,34 @@
import { Flex } from 'antd';
import type { SampleMeta } from '../../../widgets/core';
import { EmptyIllustrationCard } from '../EmptyIllustrationCard';
export const sampleMeta: SampleMeta = {
id: 'empty-illustration-card-base',
componentId: 'empty-illustration-card',
title: 'Empty Illustration Card',
description: '일러스트 영역과 제목, 설명, CTA를 함께 배치하는 empty 상태 카드입니다.',
category: 'Common',
kind: 'base',
variantLabel: 'Base',
order: 38,
features: ['docs', 'component-sample'],
};
export function Sample() {
return (
<Flex vertical gap={16}>
<EmptyIllustrationCard
eyebrow="No content"
title="아직 등록된 자동화 작업이 없습니다."
description="새 작업을 추가하면 우선순위와 진행 상태를 이 카드에서 바로 안내할 수 있습니다."
ctaLabel="작업 등록"
/>
<EmptyIllustrationCard
variant="compact"
title="검색 결과가 없습니다."
description="필터를 조정하거나 다른 키워드로 다시 찾아보세요."
ctaLabel="필터 초기화"
/>
</Flex>
);
}

View File

@@ -0,0 +1,173 @@
.evidence-attachment-strip {
width: 100%;
}
.evidence-attachment-strip__header {
margin-bottom: 12px;
}
.evidence-attachment-strip__description.ant-typography {
margin-bottom: 0;
}
.evidence-attachment-strip__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.evidence-attachment-strip__card.ant-card {
height: 100%;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06);
}
.evidence-attachment-strip__card .ant-card-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.evidence-attachment-strip__actions {
justify-content: flex-end;
flex-wrap: wrap;
max-width: 100%;
}
.evidence-attachment-strip__card-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.evidence-attachment-strip__meta {
min-width: 0;
}
.evidence-attachment-strip__meta-row {
align-items: center;
}
.evidence-attachment-strip__title {
min-width: 0;
}
.evidence-attachment-strip__description-text.ant-typography {
display: block;
margin-bottom: 0;
overflow-wrap: anywhere;
}
.evidence-attachment-strip__badge {
flex: 0 0 auto;
margin-inline-end: 0;
}
.evidence-attachment-strip__summary {
justify-content: space-between;
}
.evidence-attachment-strip__overflow.ant-typography {
margin-bottom: 0;
}
.evidence-attachment-strip__empty.ant-empty {
margin: 0;
padding: 24px 12px;
border-radius: 18px;
border: 1px dashed rgba(148, 163, 184, 0.28);
background: rgba(248, 250, 252, 0.78);
}
.evidence-attachment-preview-body {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.evidence-attachment-preview-body--compact {
min-height: 220px;
}
.evidence-attachment-preview-body__image {
width: 100%;
max-height: calc(100vh - 320px);
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.08);
object-fit: contain;
background: linear-gradient(180deg, rgba(241, 245, 249, 0.9), rgba(226, 232, 240, 0.92));
}
.evidence-attachment-preview-body__image--compact {
height: 220px;
max-height: 220px;
}
.evidence-attachment-preview-body__frame-wrap {
width: 100%;
height: calc(100vh - 320px);
min-height: 480px;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.82);
}
.evidence-attachment-preview-body__frame-wrap--compact {
height: 220px;
min-height: 220px;
}
.evidence-attachment-preview-body__frame {
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.evidence-attachment-preview-body__media-wrap,
.evidence-attachment-preview-body__audio-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 220px;
padding: 18px;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: linear-gradient(180deg, rgba(241, 245, 249, 0.84), rgba(255, 255, 255, 0.96));
}
.evidence-attachment-preview-body__media {
width: 100%;
max-height: min(72vh, 720px);
border-radius: 12px;
background: #020617;
}
.evidence-attachment-preview-body__audio {
width: min(100%, 720px);
}
.evidence-attachment-preview-body__previewer {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.evidence-attachment-preview-body__previewer .previewer-ui {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
@media (max-width: 767px) {
.evidence-attachment-strip__grid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,377 @@
import {
AudioOutlined,
CodeOutlined,
CopyOutlined,
EyeOutlined,
FileImageOutlined,
FileMarkdownOutlined,
FilePdfOutlined,
FileTextOutlined,
LinkOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { Button, Card, Empty, Flex, Space, Tag, Typography, message } from 'antd';
import type { ReactNode } from 'react';
import { InlineImage } from '../common/InlineImage';
import { PreviewerUI } from '../previewer';
import type {
EvidenceAttachmentItem,
EvidenceAttachmentKind,
EvidenceAttachmentPreviewBodyProps,
EvidenceAttachmentStripProps,
} from './types';
import './EvidenceAttachmentStrip.css';
const { Paragraph, Text } = Typography;
function getAttachmentTypeLabel(kind: EvidenceAttachmentKind) {
switch (kind) {
case 'image':
return 'Image';
case 'markdown':
return 'Markdown';
case 'code':
return 'Code';
case 'text':
return 'Text';
case 'json':
return 'JSON';
case 'preview':
return 'Preview';
case 'video':
return 'Video';
case 'audio':
return 'Audio';
case 'pdf':
return 'PDF';
case 'empty':
return 'Empty';
default:
return 'Attachment';
}
}
function getAttachmentTypeColor(kind: EvidenceAttachmentKind) {
switch (kind) {
case 'image':
return 'green';
case 'markdown':
return 'geekblue';
case 'code':
return 'cyan';
case 'text':
return 'blue';
case 'json':
return 'volcano';
case 'preview':
return 'purple';
case 'video':
return 'magenta';
case 'audio':
return 'gold';
case 'pdf':
return 'red';
case 'empty':
return 'default';
default:
return 'default';
}
}
function getAttachmentTypeIcon(kind: EvidenceAttachmentKind): ReactNode {
switch (kind) {
case 'image':
return <FileImageOutlined />;
case 'markdown':
return <FileMarkdownOutlined />;
case 'code':
return <CodeOutlined />;
case 'text':
return <FileTextOutlined />;
case 'json':
return <CodeOutlined />;
case 'preview':
return <LinkOutlined />;
case 'video':
return <PlayCircleOutlined />;
case 'audio':
return <AudioOutlined />;
case 'pdf':
return <FilePdfOutlined />;
case 'empty':
return <FileTextOutlined />;
default:
return <FileTextOutlined />;
}
}
async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
const copyValue = attachment.copyValue ?? attachment.value;
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(copyValue);
return;
}
if (typeof document === 'undefined') {
throw new Error('clipboard-unavailable');
}
const textarea = document.createElement('textarea');
textarea.value = copyValue;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
function resolvePreviewerType(kind: EvidenceAttachmentKind) {
switch (kind) {
case 'markdown':
return 'markdown';
case 'code':
return 'code';
case 'json':
return 'json';
case 'empty':
return 'empty';
default:
return 'text';
}
}
export function EvidenceAttachmentPreviewBody({
attachment,
compact = false,
height,
}: EvidenceAttachmentPreviewBodyProps) {
const previewHeight = height ?? (compact ? 220 : 'calc(100dvh - 280px)');
if (attachment.kind === 'image') {
return (
<InlineImage
className={[
'evidence-attachment-preview-body__image',
compact ? 'evidence-attachment-preview-body__image--compact' : '',
]
.filter(Boolean)
.join(' ')}
src={attachment.value}
alt={typeof attachment.title === 'string' ? attachment.title : 'attachment'}
fallbackText="첨부 이미지를 불러오지 못했습니다."
/>
);
}
if (attachment.kind === 'preview' || attachment.kind === 'pdf') {
return (
<div
className={[
'evidence-attachment-preview-body__frame-wrap',
compact ? 'evidence-attachment-preview-body__frame-wrap--compact' : '',
]
.filter(Boolean)
.join(' ')}
>
<iframe
className="evidence-attachment-preview-body__frame"
src={attachment.value}
title={typeof attachment.title === 'string' ? attachment.title : 'attachment-preview'}
loading="lazy"
referrerPolicy="no-referrer"
/>
</div>
);
}
if (attachment.kind === 'video') {
return (
<div className="evidence-attachment-preview-body__media-wrap">
<video className="evidence-attachment-preview-body__media" src={attachment.value} controls preload="metadata" />
</div>
);
}
if (attachment.kind === 'audio') {
return (
<div className="evidence-attachment-preview-body__audio-wrap">
<audio className="evidence-attachment-preview-body__audio" src={attachment.value} controls preload="metadata" />
</div>
);
}
return (
<div
className={[
'evidence-attachment-preview-body',
compact ? 'evidence-attachment-preview-body--compact' : '',
]
.filter(Boolean)
.join(' ')}
>
<div className="evidence-attachment-preview-body__previewer">
<PreviewerUI
type={resolvePreviewerType(attachment.kind)}
title={attachment.title}
description={attachment.description}
showHeader={false}
copyable={false}
maximizable={false}
value={attachment.value}
language={attachment.language}
format={attachment.format}
height={previewHeight}
/>
</div>
</div>
);
}
export function EvidenceAttachmentStrip({
attachments,
onPreview,
onCopy,
maxVisible,
compact = false,
emptyText = '표시할 첨부 자료가 없습니다.',
title,
description,
className,
previewBodyHeight,
}: EvidenceAttachmentStripProps) {
const visibleAttachments =
typeof maxVisible === 'number' && maxVisible >= 0 ? attachments.slice(0, maxVisible) : attachments;
const overflowCount = Math.max(0, attachments.length - visibleAttachments.length);
async function handleCopy(attachment: EvidenceAttachmentItem) {
try {
if (onCopy) {
await onCopy(attachment);
return;
}
await copyAttachmentValue(attachment);
message.success('복사했습니다.');
} catch {
message.error('복사에 실패했습니다.');
}
}
if (attachments.length === 0) {
return (
<div className={['evidence-attachment-strip', className].filter(Boolean).join(' ')}>
{title || description ? (
<div className="evidence-attachment-strip__header">
{title ? <Text strong>{title}</Text> : null}
{description ? (
<Paragraph type="secondary" className="evidence-attachment-strip__description">
{description}
</Paragraph>
) : null}
</div>
) : null}
<Empty className="evidence-attachment-strip__empty" description={emptyText} />
</div>
);
}
return (
<div className={['evidence-attachment-strip', className].filter(Boolean).join(' ')}>
{title || description ? (
<Flex vertical gap={4} className="evidence-attachment-strip__header">
{title ? <Text strong>{title}</Text> : null}
{description ? (
<Paragraph type="secondary" className="evidence-attachment-strip__description">
{description}
</Paragraph>
) : null}
</Flex>
) : null}
<div className="evidence-attachment-strip__grid">
{visibleAttachments.map((attachment) => (
<Card
key={attachment.key}
size="small"
className="evidence-attachment-strip__card"
extra={
<Space size={4} className="evidence-attachment-strip__actions">
<Button
aria-label="복사"
size="small"
type="text"
icon={<CopyOutlined />}
onClick={() => {
void handleCopy(attachment);
}}
/>
{attachment.linkUrl ? (
<Button
type="link"
size="small"
href={attachment.linkUrl}
target="_blank"
rel="noreferrer"
style={{ paddingInline: 0 }}
icon={<LinkOutlined />}
>
</Button>
) : null}
{onPreview ? (
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => {
void onPreview(attachment);
}}
>
</Button>
) : null}
</Space>
}
>
<Flex vertical gap={10} className="evidence-attachment-strip__card-copy">
<div className="evidence-attachment-strip__meta">
<Flex gap={8} className="evidence-attachment-strip__meta-row">
<Tag
color={getAttachmentTypeColor(attachment.kind)}
icon={getAttachmentTypeIcon(attachment.kind)}
className="evidence-attachment-strip__badge"
>
{getAttachmentTypeLabel(attachment.kind)}
</Tag>
</Flex>
<Text strong className="evidence-attachment-strip__title">
{attachment.title}
</Text>
{attachment.description ? (
<Text type="secondary" className="evidence-attachment-strip__description-text">
{attachment.description}
</Text>
) : null}
</div>
<EvidenceAttachmentPreviewBody
attachment={attachment}
compact={compact}
height={previewBodyHeight}
/>
</Flex>
</Card>
))}
</div>
{overflowCount > 0 ? (
<Flex align="center" className="evidence-attachment-strip__summary">
<Text type="secondary" className="evidence-attachment-strip__overflow">
{`추가 첨부 ${overflowCount}건은 상세 미리보기에서 확인하세요.`}
</Text>
</Flex>
) : null}
</div>
);
}

View File

@@ -0,0 +1,8 @@
export { EvidenceAttachmentPreviewBody, EvidenceAttachmentStrip } from './EvidenceAttachmentStrip';
export type {
EvidenceAttachmentActionHandler,
EvidenceAttachmentItem,
EvidenceAttachmentKind,
EvidenceAttachmentPreviewBodyProps,
EvidenceAttachmentStripProps,
} from './types';

View File

@@ -0,0 +1,75 @@
import { App } from 'antd';
import type { SampleMeta } from '../../../widgets/core';
import {
type EvidenceAttachmentItem,
EvidenceAttachmentStrip,
} from '../index';
export const sampleMeta: SampleMeta = {
id: 'evidence-attachment-strip-ui-base',
componentId: 'evidence-attachment-strip-ui',
title: 'Evidence Attachment Strip UI',
description:
'스크린샷, 문서, 로그, 링크를 같은 카드 스트립으로 정리하고 링크 열기, 복사, 미리보기 액션을 공통 제공하는 첨부 UI입니다.',
category: 'Common',
kind: 'base',
variantLabel: 'Base',
order: 58,
features: ['docs', 'component-sample'],
};
const baseAttachments: EvidenceAttachmentItem[] = [
{
key: 'image',
kind: 'image',
title: 'widget-gps-sample.png',
description: 'docs/assets/worklogs/2026-04-09/widget-gps-sample.png',
linkUrl: '/docs/assets/worklogs/2026-04-09/widget-gps-sample.png',
value: '/docs/assets/worklogs/2026-04-09/widget-gps-sample.png',
},
{
key: 'markdown',
kind: 'markdown',
title: '2026-04-09.md',
description: 'docs/worklogs/2026-04-09.md',
linkUrl: '/docs/worklogs/2026-04-09.md',
value: '# Worklog\n- Evidence Attachment Strip UI 정리\n- 작업일지 카드 미리보기 제공',
},
{
key: 'preview',
kind: 'preview',
title: 'Release Preview',
description: 'https://release.example.local/plan/board-post-14',
linkUrl: 'https://release.example.local/plan/board-post-14',
value: 'https://release.example.local/plan/board-post-14',
},
{
key: 'code',
kind: 'code',
title: 'commands.log',
description: '실행 로그',
language: 'bash',
value: 'npm run build\nnpm run preview',
},
];
export function Sample() {
const { message } = App.useApp();
return (
<EvidenceAttachmentStrip
title="산출물 Preview"
description="작업일지, 이미지, 링크, 로그를 같은 패턴으로 정리합니다."
attachments={baseAttachments}
compact
onPreview={(attachment) => {
message.info(`${String(attachment.title)} 미리보기 진입`);
}}
onCopy={async (attachment) => {
await navigator.clipboard.writeText(attachment.copyValue ?? attachment.value);
message.success(`${String(attachment.title)} 복사`);
}}
maxVisible={3}
/>
);
}

View File

@@ -0,0 +1,144 @@
import { App, Card, Flex, Modal, Space, Switch, Typography } from 'antd';
import { useState } from 'react';
import type { SampleMeta } from '../../../widgets/core';
import {
type EvidenceAttachmentItem,
EvidenceAttachmentPreviewBody,
EvidenceAttachmentStrip,
} from '../index';
const { Paragraph, Text } = Typography;
export const sampleMeta: SampleMeta = {
id: 'evidence-attachment-strip-ui-showcase',
componentId: 'evidence-attachment-strip-ui',
title: 'Evidence Attachment Strip Showcase',
description: '지원 타입별 카드와 모바일 compact 배치를 함께 확인하는 샘플입니다.',
category: 'Components',
kind: 'feature',
variantLabel: 'Showcase',
order: 59,
features: ['docs', 'component-sample'],
};
const showcaseAttachments: EvidenceAttachmentItem[] = [
{
key: 'image',
kind: 'image',
title: '화면 캡처',
description: '스크린샷 산출물',
linkUrl: '/docs/assets/worklogs/2026-04-09/widget-gps-sample.png',
value: '/docs/assets/worklogs/2026-04-09/widget-gps-sample.png',
},
{
key: 'markdown',
kind: 'markdown',
title: '작업일지',
description: '마크다운 문서',
value: '# Summary\n- Preview strip 적용\n- 공통 액션 정리',
},
{
key: 'code',
kind: 'code',
title: 'patch.diff',
description: '코드 변경',
language: 'diff',
value: '- old card grid\n+ EvidenceAttachmentStrip',
},
{
key: 'text',
kind: 'text',
title: 'memo.txt',
description: '단순 텍스트',
value: 'Plan/Board 공통 UI 후보 검토 메모',
},
{
key: 'json',
kind: 'json',
title: 'meta.json',
description: '메타 정보',
value: '{\n "kind": "json",\n "count": 4\n}',
},
{
key: 'video',
kind: 'video',
title: 'demo.mp4',
description: '비디오 산출물',
linkUrl: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
value: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
},
{
key: 'audio',
kind: 'audio',
title: 'voice.ogg',
description: '오디오 산출물',
linkUrl: 'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3',
value: 'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3',
},
{
key: 'pdf',
kind: 'pdf',
title: 'sample.pdf',
description: 'PDF 문서',
linkUrl: 'https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf',
value: 'https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf',
},
{
key: 'empty',
kind: 'empty',
title: 'unsupported.bin',
description: '지원하지 않는 형식',
value: '미리보기를 지원하지 않는 형식입니다.',
},
];
export function Sample() {
const { message } = App.useApp();
const [compact, setCompact] = useState(false);
const [selectedAttachment, setSelectedAttachment] = useState<EvidenceAttachmentItem | null>(null);
return (
<Flex vertical gap={16}>
<Card
title="Evidence Attachment Strip UI"
extra={
<Space align="center">
<Text type="secondary">Compact</Text>
<Switch checked={compact} onChange={setCompact} />
</Space>
}
>
<Paragraph>
Plan/Board .
</Paragraph>
<EvidenceAttachmentStrip
title="지원 타입 전체"
description="이미지, markdown, preview, 코드, 텍스트, json, video, audio, pdf, empty"
attachments={showcaseAttachments}
compact={compact}
onPreview={(attachment) => {
setSelectedAttachment(attachment);
}}
onCopy={async (attachment) => {
await navigator.clipboard.writeText(attachment.copyValue ?? attachment.value);
message.success(`${String(attachment.title)} 복사`);
}}
/>
</Card>
<Modal
open={Boolean(selectedAttachment)}
title={selectedAttachment?.title ?? 'Attachment Preview'}
footer={null}
width={1080}
onCancel={() => {
setSelectedAttachment(null);
}}
>
{selectedAttachment ? (
<EvidenceAttachmentPreviewBody attachment={selectedAttachment} />
) : null}
</Modal>
</Flex>
);
}

View File

@@ -0,0 +1,49 @@
import type { ReactNode } from 'react';
import type { PreviewerFormat } from '../../previewer/types';
export type EvidenceAttachmentKind =
| 'image'
| 'markdown'
| 'code'
| 'text'
| 'json'
| 'preview'
| 'video'
| 'audio'
| 'pdf'
| 'empty';
export type EvidenceAttachmentItem = {
key: string;
kind: EvidenceAttachmentKind;
title: ReactNode;
description?: ReactNode;
value: string;
linkUrl?: string;
language?: string;
format?: PreviewerFormat;
copyValue?: string;
};
export type EvidenceAttachmentActionHandler = (
attachment: EvidenceAttachmentItem,
) => void | Promise<void>;
export type EvidenceAttachmentPreviewBodyProps = {
attachment: EvidenceAttachmentItem;
compact?: boolean;
height?: number | string;
};
export type EvidenceAttachmentStripProps = {
attachments: EvidenceAttachmentItem[];
onPreview?: EvidenceAttachmentActionHandler;
onCopy?: EvidenceAttachmentActionHandler;
maxVisible?: number;
compact?: boolean;
emptyText?: ReactNode;
title?: ReactNode;
description?: ReactNode;
className?: string;
previewBodyHeight?: number | string;
};

View File

@@ -0,0 +1,7 @@
export type {
EvidenceAttachmentActionHandler,
EvidenceAttachmentItem,
EvidenceAttachmentKind,
EvidenceAttachmentPreviewBodyProps,
EvidenceAttachmentStripProps,
} from './evidence-attachment-strip';

View File

@@ -0,0 +1,7 @@
.form-field {
margin-bottom: 16px;
}
.form-field--disabled {
opacity: 0.72;
}

View File

@@ -0,0 +1,89 @@
import { Form, Typography } from 'antd';
import type { FormItemProps } from 'antd';
import type { ReactElement, ReactNode } from 'react';
import { cloneElement, isValidElement } from 'react';
import './FormField.css';
const { Text } = Typography;
export type FormFieldRenderState = {
disabled?: boolean;
status?: 'error' | 'warning';
required?: boolean;
};
export type FormFieldProps = Omit<FormItemProps, 'children' | 'help' | 'required'> & {
label: ReactNode;
required?: boolean;
help?: ReactNode;
error?: ReactNode;
disabled?: boolean;
children: ReactNode | ((state: FormFieldRenderState) => ReactNode);
};
function renderChild(
children: FormFieldProps['children'],
state: FormFieldRenderState,
) {
if (typeof children === 'function') {
return children(state);
}
if (!isValidElement(children)) {
return children;
}
const child = children as ReactElement<Record<string, unknown>>;
return cloneElement(child, {
disabled: child.props.disabled ?? state.disabled,
status: child.props.status ?? state.status,
});
}
export function FormField({
label,
required,
help,
error,
disabled,
children,
validateStatus,
extra,
className,
...restProps
}: FormFieldProps) {
const status = error ? 'error' : validateStatus;
const childStatus = status === 'error' || status === 'warning' ? status : undefined;
const mergedClassName = [
'form-field',
disabled ? 'form-field--disabled' : '',
className ?? '',
]
.filter(Boolean)
.join(' ');
return (
<Form.Item
{...restProps}
className={mergedClassName}
label={label}
required={required}
validateStatus={status}
help={error ?? help}
extra={
extra ? (
extra
) : required ? (
<Text type="secondary"> .</Text>
) : undefined
}
>
{renderChild(children, {
disabled,
status: childStatus,
required,
})}
</Form.Item>
);
}

View File

@@ -0,0 +1 @@
export * from './FormField';

View File

@@ -0,0 +1,49 @@
import { Form } from 'antd';
import { useState } from 'react';
import type { SampleMeta } from '../../../widgets/core';
import { InputUI } from '../../inputs/primitives/input';
import { SelectUI } from '../../inputs/select';
import { FormField } from '../FormField';
const options = [
{ code: 'plan', value: 'Plan' },
{ code: 'board', value: 'Board' },
{ code: 'settings', value: 'Settings' },
];
export const sampleMeta: SampleMeta = {
id: 'form-field-base',
componentId: 'form-field',
title: 'Form Field Wrapper',
description: 'label, required, help, error, disabled 상태를 입력 컴포넌트 앞단에서 통일하는 필드 래퍼입니다.',
category: 'Common',
kind: 'base',
variantLabel: 'Base',
order: 30,
features: ['docs', 'component-sample'],
};
export function Sample() {
const [title, setTitle] = useState('');
const error = title.trim() ? undefined : '제목을 입력하세요.';
return (
<Form layout="vertical">
<FormField label="게시글 제목" required help="저장 전 검증 메시지를 같은 위치에 표시합니다." error={error}>
<InputUI
value={title}
placeholder="제목 입력"
onChange={(event) => {
setTitle(event.target.value);
}}
/>
</FormField>
<FormField label="연결 화면">
<SelectUI data={options} defaultValue="plan" />
</FormField>
<FormField label="잠긴 필드" disabled>
<InputUI defaultValue="자동화 접수 후 수정할 수 없습니다." />
</FormField>
</Form>
);
}

View File

@@ -0,0 +1,78 @@
import { Checkbox, Flex, Select } from 'antd';
import type { SelectProps } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import type { CheckComboUIProps, SelectOptionItem } from './types';
function normalizeKeyword(text: string) {
return text.trim().toLowerCase();
}
export function CheckComboUI({
data,
value,
defaultValue,
onChange,
showSearch = true,
allowClear = true,
placeholder = '항목을 선택하세요',
...restProps
}: CheckComboUIProps) {
const [selectedCodes, setSelectedCodes] = useState<string[]>(() => value ?? defaultValue ?? []);
useEffect(() => {
if (value !== undefined) {
setSelectedCodes(value);
}
}, [value]);
const options = useMemo<SelectProps['options']>(
() =>
data.map((item) => ({
value: item.code,
label: item.value,
item,
})),
[data],
);
const itemMap = useMemo(
() =>
new Map<string, SelectOptionItem>(data.map((item) => [item.code, item])),
[data],
);
const selectedCodeSet = useMemo(() => new Set(selectedCodes), [selectedCodes]);
return (
<Select
{...restProps}
mode="multiple"
value={selectedCodes}
showSearch={showSearch}
allowClear={allowClear}
placeholder={placeholder}
options={options}
optionFilterProp="label"
maxTagCount="responsive"
filterOption={(input, option) =>
normalizeKeyword(String(option?.label ?? '')).includes(normalizeKeyword(input))
}
optionRender={(option) => (
<Flex align="center" gap={8}>
<Checkbox checked={selectedCodeSet.has(String(option.value))} />
<span>{String(option.label)}</span>
</Flex>
)}
onChange={(nextCodes) => {
const normalizedCodes = (nextCodes ?? []) as string[];
setSelectedCodes(normalizedCodes);
onChange?.(
normalizedCodes,
normalizedCodes
.map((code) => itemMap.get(code))
.filter((item): item is SelectOptionItem => item !== undefined),
);
}}
/>
);
}

View File

@@ -0,0 +1,6 @@
export { CheckComboUI } from './CheckComboUI';
export {
createCheckComboPlaceholderPlugin,
createCheckComboSortPlugin,
} from './plugins';
export type { CheckComboUIProps, SelectOptionItem } from './types';

View File

@@ -0,0 +1,18 @@
import type { PropsPlugin } from '../../../../types/component-plugin';
import type { CheckComboUIProps } from '../types';
export function createCheckComboPlaceholderPlugin(
placeholder: string,
): PropsPlugin<CheckComboUIProps> {
return (props) => ({
...props,
placeholder,
});
}
export function createCheckComboSortPlugin(): PropsPlugin<CheckComboUIProps> {
return (props) => ({
...props,
data: [...props.data].sort((left, right) => left.value.localeCompare(right.value)),
});
}

View File

@@ -0,0 +1,4 @@
export {
createCheckComboPlaceholderPlugin,
createCheckComboSortPlugin,
} from './check-combo.plugin';

View File

@@ -0,0 +1,37 @@
import { useState } from 'react';
import type { SampleMeta } from '../../../../widgets/core';
import { CheckComboUI } from '../CheckComboUI';
const data = [
{ code: 'WH-001', value: '서울 물류센터' },
{ code: 'WH-002', value: '김포 물류센터' },
{ code: 'WH-003', value: '부산 물류센터' },
{ code: 'WH-004', value: '대전 허브센터' },
];
export const sampleMeta: SampleMeta = {
id: 'check-combo-input-base',
componentId: 'check-combo-input',
title: 'Check Combo Input',
description: 'code/value 데이터를 받아 code[]를 값으로 유지하는 체크형 combo input 샘플입니다.',
category: 'Inputs',
kind: 'base',
variantLabel: 'Base',
order: 42,
features: ['docs'],
};
export function Sample() {
const [value, setValue] = useState<string[]>(['WH-001', 'WH-003']);
return (
<CheckComboUI
data={data}
value={value}
placeholder="센터명을 검색하고 다중 선택하세요"
onChange={(nextCodes) => {
setValue(nextCodes);
}}
/>
);
}

View File

@@ -0,0 +1,72 @@
import { Card, Flex, Typography } from 'antd';
import { useMemo, useState } from 'react';
import type { SampleMeta } from '../../../../widgets/core';
import { plugins } from '../../../../types/component-plugin';
import {
createCheckComboPlaceholderPlugin,
createCheckComboSortPlugin,
} from '../plugins';
import { CheckComboUI } from '../CheckComboUI';
import type { CheckComboUIProps, SelectOptionItem } from '../types';
const { Paragraph, Text } = Typography;
export const sampleMeta: SampleMeta = {
id: 'check-combo-input',
componentId: 'check-combo-input',
title: 'Check Combo Input',
description: 'code/value 데이터를 받아 code[]를 값으로 유지하는 체크형 combo input 샘플입니다.',
category: 'Inputs',
kind: 'feature',
variantLabel: 'Showcase',
order: 42,
features: ['docs'],
};
export function Sample() {
const [selectedCodes, setSelectedCodes] = useState<string[]>(['WH-001', 'WH-003']);
const data = useMemo<SelectOptionItem[]>(
() => [
{ code: 'WH-001', value: '서울 물류센터' },
{ code: 'WH-002', value: '김포 물류센터' },
{ code: 'WH-003', value: '부산 물류센터' },
{ code: 'WH-004', value: '대전 허브센터' },
],
[],
);
const comboProps = plugins<CheckComboUIProps>(
{
data,
value: selectedCodes,
onChange: (nextCodes) => {
setSelectedCodes(nextCodes);
},
},
[
createCheckComboPlaceholderPlugin('센터명을 검색하고 다중 선택하세요'),
createCheckComboSortPlugin(),
],
);
const selectedValues = data
.filter((item) => selectedCodes.includes(item.code))
.map((item) => item.value)
.join(', ');
return (
<Card title="Check Combo Input Sample" extra={<Text code>samples/Sample.tsx</Text>}>
<Paragraph>
. <Text strong>code[]</Text>
, <Text strong>value</Text> .
</Paragraph>
<Flex vertical gap="small">
<CheckComboUI {...comboProps} />
<Text> codes: {selectedCodes.join(', ') || '-'}</Text>
<Text> values: {selectedValues || '-'}</Text>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1,16 @@
import type { SelectProps } from 'antd';
export type SelectOptionItem = {
code: string;
value: string;
};
export type CheckComboUIProps = Omit<
SelectProps,
'mode' | 'options' | 'value' | 'defaultValue' | 'onChange'
> & {
data: SelectOptionItem[];
value?: string[];
defaultValue?: string[];
onChange?: (codes: string[], items: SelectOptionItem[]) => void;
};

View File

@@ -0,0 +1 @@
export type { CheckComboUIProps, SelectOptionItem } from './check-combo';

View File

@@ -0,0 +1,111 @@
import { Flex } from 'antd';
import type { InputProps, InputRef } from 'antd';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { InputUI } from '../../primitives/input';
import { createMultiSegmentValidatorPlugin } from './plugins';
import type { MultiInputParts } from './types';
type MultiInputChangeEvent = Parameters<NonNullable<InputProps['onChange']>>[0];
export type MultiInputUIProps = {
value?: string;
defaultValue?: string;
onChange?: InputProps['onChange'];
disabled?: boolean;
size?: InputProps['size'];
status?: InputProps['status'];
variant?: InputProps['variant'];
allowClear?: boolean;
};
function splitValue(value?: string): MultiInputParts {
const digits = (value ?? '').replace(/\D/g, '').slice(0, 11);
return [digits.slice(0, 3), digits.slice(3, 7), digits.slice(7, 11)];
}
function createChangeEvent(value: string): MultiInputChangeEvent {
return {
target: { value },
currentTarget: { value },
} as MultiInputChangeEvent;
}
export const MultiInputUI = forwardRef<InputRef, MultiInputUIProps>(function MultiInputUI(
{ value, defaultValue, onChange, disabled, size, status, variant, allowClear },
ref,
) {
const firstRef = useRef<InputRef>(null);
const secondRef = useRef<InputRef>(null);
const thirdRef = useRef<InputRef>(null);
const [parts, setParts] = useState<MultiInputParts>(() => splitValue(value ?? defaultValue));
useImperativeHandle(ref, () => firstRef.current as InputRef, []);
useEffect(() => {
if (value !== undefined) {
setParts(splitValue(value));
}
}, [value]);
const updatePart = (index: 0 | 1 | 2, nextPart: string) => {
setParts((previousParts) => {
const nextParts: MultiInputParts = [...previousParts] as MultiInputParts;
nextParts[index] = nextPart.replace(/\D/g, '').slice(0, index === 0 ? 3 : 4);
onChange?.(createChangeEvent(nextParts.join('')));
return nextParts;
});
};
const commonProps = {
disabled,
size,
status,
variant,
allowClear,
inputMode: 'numeric' as const,
};
return (
<Flex gap="small" align="center">
<InputUI
{...commonProps}
ref={firstRef}
value={parts[0]}
maxLength={3}
placeholder="010"
commitPlugins={[createMultiSegmentValidatorPlugin(3)]}
onChange={(event) => {
updatePart(0, event.target.value);
if (event.target.value.length === 3) {
secondRef.current?.focus();
}
}}
/>
<InputUI
{...commonProps}
ref={secondRef}
value={parts[1]}
maxLength={4}
placeholder="1234"
commitPlugins={[createMultiSegmentValidatorPlugin(4)]}
onChange={(event) => {
updatePart(1, event.target.value);
if (event.target.value.length === 4) {
thirdRef.current?.focus();
}
}}
/>
<InputUI
{...commonProps}
ref={thirdRef}
value={parts[2]}
maxLength={4}
placeholder="5678"
commitPlugins={[createMultiSegmentValidatorPlugin(4)]}
onChange={(event) => {
updatePart(2, event.target.value);
}}
/>
</Flex>
);
});

View File

@@ -0,0 +1,4 @@
export { MultiInputUI } from './MultiInputUI';
export type { MultiInputUIProps } from './MultiInputUI';
export * from './plugins';
export * from './types';

View File

@@ -0,0 +1,4 @@
export {
createMultiInputValidatorPlugin,
createMultiSegmentValidatorPlugin,
} from './multi-input.plugin';

View File

@@ -0,0 +1,15 @@
import type { InputCommitPlugin } from '../../../primitives/input';
export const createMultiInputValidatorPlugin =
(): InputCommitPlugin =>
({ nextValue }) => {
const digits = nextValue.replace(/\D/g, '');
return digits.length === 10 || digits.length === 11;
};
export const createMultiSegmentValidatorPlugin =
(segmentLength: 3 | 4): InputCommitPlugin =>
({ nextValue }) => {
const digits = nextValue.replace(/\D/g, '');
return digits.length === segmentLength;
};

View File

@@ -0,0 +1,28 @@
import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core';
import { MultiInputUI } from '../MultiInputUI';
export const sampleMeta: SampleMeta = {
id: 'multi-input-base',
componentId: 'multi-input',
title: 'Multi Input',
description: '하나의 값을 여러 입력칸으로 나누어 편집하는 복합 입력 컴포넌트입니다.',
category: 'Inputs',
kind: 'base',
variantLabel: 'Base',
order: 30,
features: ['docs'],
};
export function Sample() {
const [value, setValue] = useState('01012345678');
return (
<MultiInputUI
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>
);
}

View File

@@ -0,0 +1,41 @@
import { Card, Flex, Typography } from 'antd';
import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core';
import { MultiInputUI } from '../MultiInputUI';
const { Paragraph, Text } = Typography;
export const sampleMeta: SampleMeta = {
id: 'multi-input',
componentId: 'multi-input',
title: 'Multi Input',
description: '3자리 / 4자리 / 4자리 입력을 조합하는 기본형 multi input 샘플입니다.',
category: 'Inputs',
kind: 'feature',
variantLabel: 'Showcase',
order: 30,
features: ['docs'],
};
export function Sample() {
const [value, setValue] = useState('01012345678');
return (
<Card title="Multi Input Sample" extra={<Text code>samples/Sample.tsx</Text>}>
<Paragraph>
<Text strong>3 / 4 / 4</Text> .
</Paragraph>
<Flex vertical gap="small">
<MultiInputUI
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>
<Text> : {value}</Text>
<Text type="secondary"> .</Text>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1 @@
export type { MultiInputParts, MultiInputValue } from './multi-input';

View File

@@ -0,0 +1,2 @@
export type MultiInputValue = string;
export type MultiInputParts = [string, string, string];

View File

@@ -0,0 +1,66 @@
import { SearchOutlined } from '@ant-design/icons';
import { Button, Flex, Input } from 'antd';
import type { InputProps } from 'antd';
import { InputUI } from '../primitives/input';
import type { PopupUIProps } from './types';
export function PopupUI({
type = 'default',
value,
defaultValue,
resultValue,
onChange,
onButtonClick,
buttonText = '검색',
buttonDisabled,
inputPlaceholder,
resultPlaceholder = '선택 결과',
disabled,
size,
status,
variant,
allowClear,
}: PopupUIProps) {
const sharedInputProps: Pick<
InputProps,
'disabled' | 'size' | 'status' | 'variant' | 'allowClear'
> = {
disabled,
size,
status,
variant,
allowClear,
};
return (
<Flex gap={0} align="stretch" className={`popup-input-ui popup-input-ui--${type}`}>
<InputUI
{...sharedInputProps}
value={value}
defaultValue={defaultValue}
onChange={onChange}
placeholder={inputPlaceholder}
className="popup-input-ui__input"
/>
<Button
type="primary"
size={size}
disabled={disabled || buttonDisabled}
onClick={onButtonClick}
className="popup-input-ui__button"
icon={type === 'search' ? <SearchOutlined /> : undefined}
>
{type === 'search' ? null : buttonText}
</Button>
<Input
{...sharedInputProps}
readOnly
value={resultValue}
placeholder={resultPlaceholder}
className="popup-input-ui__result"
/>
</Flex>
);
}

View File

@@ -0,0 +1,6 @@
export { PopupUI } from './PopupUI';
export {
createPopupButtonTextPlugin,
createPopupResultPlaceholderPlugin,
} from './plugins';
export type { PopupUIProps } from './types';

View File

@@ -0,0 +1,4 @@
export {
createPopupButtonTextPlugin,
createPopupResultPlaceholderPlugin,
} from './popup.plugin';

View File

@@ -0,0 +1,18 @@
import type { PropsPlugin } from '../../../../types/component-plugin';
import type { PopupUIProps } from '../types';
export function createPopupButtonTextPlugin(buttonText: string): PropsPlugin<PopupUIProps> {
return (props) => ({
...props,
buttonText,
});
}
export function createPopupResultPlaceholderPlugin(
resultPlaceholder: string,
): PropsPlugin<PopupUIProps> {
return (props) => ({
...props,
resultPlaceholder,
});
}

View File

@@ -0,0 +1,35 @@
import { useState } from 'react';
import type { SampleMeta } from '../../../../widgets/core';
import { PopupUI } from '../PopupUI';
export const sampleMeta: SampleMeta = {
id: 'popup-input-base',
componentId: 'popup-input',
title: 'Popup Input',
description: '검색 버튼과 결과 필드를 함께 제공하는 팝업형 입력 컴포넌트입니다.',
category: 'Inputs',
kind: 'base',
variantLabel: 'Base',
order: 40,
features: ['docs'],
};
export function Sample() {
const [value, setValue] = useState('납품처');
const [result, setResult] = useState('서울 물류센터');
return (
<PopupUI
type="search"
value={value}
resultValue={result}
inputPlaceholder="검색어 입력"
onChange={(event) => {
setValue(event.target.value);
}}
onButtonClick={() => {
setResult(value ? `${value} 선택 결과` : '선택 결과 없음');
}}
/>
);
}

View File

@@ -0,0 +1,60 @@
import { Card, Flex, Typography } from 'antd';
import { useState } from 'react';
import type { SampleMeta } from '../../../../widgets/core';
import { plugins } from '../../../../types/component-plugin';
import { createPopupButtonTextPlugin, createPopupResultPlaceholderPlugin } from '../plugins';
import { PopupUI } from '../PopupUI';
import type { PopupUIProps } from '../types';
const { Paragraph, Text } = Typography;
export const sampleMeta: SampleMeta = {
id: 'popup-input',
componentId: 'popup-input',
title: 'Popup Input',
description: '[input][button][readonly input] 형태의 popup input 샘플입니다.',
category: 'Inputs',
kind: 'feature',
variantLabel: 'Showcase',
order: 40,
features: ['docs'],
};
export function Sample() {
const [keyword, setKeyword] = useState('납품처');
const [result, setResult] = useState('서울 물류센터');
const popupProps = plugins<PopupUIProps>(
{
type: 'search',
value: keyword,
resultValue: result,
inputPlaceholder: '검색어 입력',
onChange: (event) => {
setKeyword(event.target.value);
},
onButtonClick: () => {
setResult(keyword ? `${keyword} 선택 결과` : '선택 결과 없음');
},
},
[
createPopupButtonTextPlugin('팝업'),
createPopupResultPlaceholderPlugin('팝업 선택값'),
],
);
return (
<Card title="Popup Input Sample" extra={<Text code>samples/Sample.tsx</Text>}>
<Paragraph>
popup input . input에 ,
readonly input에 .
</Paragraph>
<Flex vertical gap="small">
<PopupUI {...popupProps} />
<Text> : {keyword}</Text>
<Text> : {result}</Text>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1 @@
export type { PopupUIProps } from './popup';

View File

@@ -0,0 +1,19 @@
import type { InputProps } from 'antd';
export type PopupUIProps = {
type?: 'default' | 'search';
value?: string;
defaultValue?: string;
resultValue?: string;
onChange?: InputProps['onChange'];
onButtonClick?: () => void;
buttonText?: string;
buttonDisabled?: boolean;
inputPlaceholder?: string;
resultPlaceholder?: string;
disabled?: boolean;
size?: InputProps['size'];
status?: InputProps['status'];
variant?: InputProps['variant'];
allowClear?: boolean;
};

View File

@@ -0,0 +1,88 @@
import { Input } from 'antd';
import type { InputProps, InputRef } from 'antd';
import type { FocusEvent, KeyboardEvent } from 'react';
import { forwardRef, useEffect, useState } from 'react';
import type { InputCommitPlugin, InputCommitTiming } from './types';
type AntdInputChangeEvent = Parameters<NonNullable<InputProps['onChange']>>[0];
export type InputUIProps = Omit<InputProps, 'onChange'> & {
onChange?: InputProps['onChange'];
commitPlugins?: ReadonlyArray<InputCommitPlugin>;
};
function normalizeValue(value: InputProps['value'] | InputProps['defaultValue']) {
if (value === undefined || value === null) {
return '';
}
return String(value);
}
function toCommittedChangeEvent(
event: KeyboardEvent<HTMLInputElement> | FocusEvent<HTMLInputElement>,
): AntdInputChangeEvent {
return {
...event,
target: event.currentTarget,
currentTarget: event.currentTarget,
} as AntdInputChangeEvent;
}
export const InputUI = forwardRef<InputRef, InputUIProps>(function InputUI(
{ value, defaultValue, onChange, onBlur, onPressEnter, commitPlugins = [], ...restProps },
ref,
) {
const [draftValue, setDraftValue] = useState(() => normalizeValue(value ?? defaultValue));
const [committedValue, setCommittedValue] = useState(() => normalizeValue(value ?? defaultValue));
useEffect(() => {
if (value !== undefined) {
const normalizedValue = normalizeValue(value);
setDraftValue(normalizedValue);
setCommittedValue(normalizedValue);
}
}, [value]);
const tryCommit = (
event: KeyboardEvent<HTMLInputElement> | FocusEvent<HTMLInputElement>,
timing: InputCommitTiming,
) => {
const nextValue = event.currentTarget.value;
const isValid = commitPlugins.every((plugin) =>
plugin({
nextValue,
previousValue: committedValue,
timing,
}),
);
if (!isValid) {
setDraftValue(committedValue);
return;
}
setCommittedValue(nextValue);
onChange?.(toCommittedChangeEvent(event));
};
return (
<Input
{...restProps}
ref={ref}
value={draftValue}
onChange={(event) => {
setDraftValue(event.target.value);
}}
onPressEnter={(event) => {
tryCommit(event, 'press-enter');
onPressEnter?.(event);
}}
onBlur={(event) => {
tryCommit(event, 'blur');
onBlur?.(event);
}}
/>
);
});

View File

@@ -0,0 +1,4 @@
export { InputUI } from './InputUI';
export type { InputUIProps, InputUIProps as DeferredInputProps } from './InputUI';
export * from './plugins';
export * from './types';

View File

@@ -0,0 +1 @@
export { createValidInputPlugin, trimInputValuePlugin } from './input.plugin';

View File

@@ -0,0 +1,22 @@
import type { PropsPlugin } from '../../../../../types/component-plugin';
import type { InputUIProps } from '../InputUI';
import type { InputValidator } from '../types';
export const trimInputValuePlugin =
(): PropsPlugin<InputUIProps> =>
(props) => ({
...props,
onBlur: (event) => {
event.target.value = event.target.value.trim();
props.onBlur?.(event);
},
});
export const createValidInputPlugin =
(onValid: InputValidator) =>
({ nextValue, previousValue, timing }: Parameters<InputValidator>[0]) =>
onValid({
nextValue,
previousValue,
timing,
});

View File

@@ -0,0 +1,29 @@
import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core';
import { InputUI } from '../InputUI';
export const sampleMeta: SampleMeta = {
id: 'input-base',
componentId: 'input',
title: 'Base Input',
description: '입력 중에는 draft 상태를 유지하고 Enter 또는 blur 시점에 값을 확정하는 기본형 InputUI 샘플입니다.',
category: 'Inputs',
kind: 'base',
variantLabel: 'Base',
order: 20,
features: ['docs'],
};
export function Sample() {
const [value, setValue] = useState('초기값');
return (
<InputUI
value={value}
placeholder="입력 후 Enter 또는 blur"
onChange={(event) => {
setValue(event.target.value);
}}
/>
);
}

View File

@@ -0,0 +1,47 @@
import { Card, Flex, Typography } from 'antd';
import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core';
import { InputUI } from '../InputUI';
const { Paragraph, Text } = Typography;
export const sampleMeta: SampleMeta = {
id: 'deferred-input',
componentId: 'input',
title: 'Base Input',
description: '입력 중에는 draft 상태만 유지하고 Enter 또는 blur 시점에만 값을 확정하는 기본형 InputUI 샘플입니다.',
category: 'Inputs',
kind: 'feature',
variantLabel: 'Showcase',
order: 20,
features: ['docs'],
};
export function Sample() {
const [committedValue, setCommittedValue] = useState('초기값');
const [commitCount, setCommitCount] = useState(0);
return (
<Card title="Base Input Sample" extra={<Text code>samples/Sample.tsx</Text>}>
<Paragraph>
, <Text strong>Enter</Text> <Text strong>blur</Text>{' '}
.
</Paragraph>
<Flex vertical gap="small">
<InputUI
placeholder="입력 후 Enter 또는 blur"
value={committedValue}
onChange={(event) => {
setCommittedValue(event.target.value);
setCommitCount((count) => count + 1);
}}
/>
<Text> : {committedValue}</Text>
<Text type="secondary"> : {commitCount}</Text>
</Flex>
</Card>
);
}
export const InputSample = Sample;

View File

@@ -0,0 +1,63 @@
import { Card, Divider, Flex, Typography } from 'antd';
import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core';
import { createValidInputPlugin } from '../plugins';
import { InputUI } from '../InputUI';
const { Paragraph, Text, Title } = Typography;
export const sampleMeta: SampleMeta = {
id: 'valid-input',
componentId: 'input',
title: 'Valid Input',
description: 'InputUI에 validation plugin을 추가해 유효할 때만 값을 확정하는 확장 샘플입니다.',
category: 'Inputs',
kind: 'plugin',
variantLabel: 'Validation Plugin',
order: 21,
features: ['docs'],
};
export function Sample() {
const [validValue, setValidValue] = useState('hello');
const [uppercaseValue, setUppercaseValue] = useState('ABC');
const minLengthPlugin = createValidInputPlugin(({ nextValue }) => nextValue.trim().length >= 3);
const uppercasePlugin = createValidInputPlugin(({ nextValue }) => /^[A-Z]+$/.test(nextValue));
return (
<Card title="Validation Plugin Sample" extra={<Text code>samples/ValidInputSample.tsx</Text>}>
<Paragraph>
<Text strong>`InputUI`</Text> , {' '}
<Text strong>`createValidInputPlugin`</Text> .
</Paragraph>
<Flex vertical gap="small">
<Title level={5}>Minimum Length Validation</Title>
<InputUI
placeholder="3글자 이상만 반영"
value={validValue}
commitPlugins={[minLengthPlugin]}
onChange={(event) => {
setValidValue(event.target.value);
}}
/>
<Text> : {validValue}</Text>
<Text type="secondary">3 .</Text>
<Divider />
<Title level={5}>Uppercase Validation</Title>
<InputUI
placeholder="영문 대문자만 반영"
value={uppercaseValue}
commitPlugins={[uppercasePlugin]}
onChange={(event) => {
setUppercaseValue(event.target.value);
}}
/>
<Text> : {uppercaseValue}</Text>
<Text type="secondary">, , .</Text>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1,6 @@
export type {
InputCommitContext,
InputCommitPlugin,
InputCommitTiming,
InputValidator,
} from './input';

View File

@@ -0,0 +1,11 @@
export type InputCommitTiming = 'press-enter' | 'blur';
export type InputCommitContext = {
nextValue: string;
previousValue: string;
timing: InputCommitTiming;
};
export type InputCommitPlugin = (context: InputCommitContext) => boolean;
export type InputValidator = (context: InputCommitContext) => boolean;

View File

@@ -0,0 +1,54 @@
import { Select } from 'antd';
import type { SelectProps } from 'antd';
import { useMemo } from 'react';
import type { SelectOptionItem, SelectUIProps } from './types';
function normalizeKeyword(text: string) {
return text.trim().toLowerCase();
}
export function SelectUI({
data,
value,
defaultValue,
onChange,
showSearch = true,
allowClear = true,
placeholder = '항목을 선택하세요',
...restProps
}: SelectUIProps) {
const options = useMemo<SelectProps<string>['options']>(
() =>
data.map((item) => ({
value: item.code,
label: item.value,
item,
})),
[data],
);
const itemMap = useMemo(
() =>
new Map<string, SelectOptionItem>(data.map((item) => [item.code, item])),
[data],
);
return (
<Select<string>
{...restProps}
value={value}
defaultValue={defaultValue}
showSearch={showSearch}
allowClear={allowClear}
placeholder={placeholder}
options={options}
optionFilterProp="label"
filterOption={(input, option) =>
normalizeKeyword(String(option?.label ?? '')).includes(normalizeKeyword(input))
}
onChange={(nextCode) => {
onChange?.(nextCode, nextCode ? itemMap.get(nextCode) : undefined);
}}
/>
);
}

View File

@@ -0,0 +1,3 @@
export { SelectUI } from './SelectUI';
export { createSelectPlaceholderPlugin, createSelectSortPlugin } from './plugins';
export type { SelectOptionItem, SelectUIProps } from './types';

View File

@@ -0,0 +1 @@
export { createSelectPlaceholderPlugin, createSelectSortPlugin } from './select.plugin';

View File

@@ -0,0 +1,16 @@
import type { PropsPlugin } from '../../../../types/component-plugin';
import type { SelectUIProps } from '../types';
export function createSelectPlaceholderPlugin(placeholder: string): PropsPlugin<SelectUIProps> {
return (props) => ({
...props,
placeholder,
});
}
export function createSelectSortPlugin(): PropsPlugin<SelectUIProps> {
return (props) => ({
...props,
data: [...props.data].sort((left, right) => left.value.localeCompare(right.value)),
});
}

View File

@@ -0,0 +1,37 @@
import { useState } from 'react';
import type { SampleMeta } from '../../../../widgets/core';
import { SelectUI } from '../SelectUI';
const data = [
{ code: 'WH-001', value: '서울 물류센터' },
{ code: 'WH-002', value: '김포 물류센터' },
{ code: 'WH-003', value: '부산 물류센터' },
{ code: 'WH-004', value: '대전 허브센터' },
];
export const sampleMeta: SampleMeta = {
id: 'select-input-base',
componentId: 'select-input',
title: 'Select Input',
description: 'code/value 데이터를 받아 code를 값으로 유지하는 필터형 select combo 샘플입니다.',
category: 'Inputs',
kind: 'base',
variantLabel: 'Base',
order: 41,
features: ['docs'],
};
export function Sample() {
const [value, setValue] = useState<string | undefined>('WH-001');
return (
<SelectUI
data={data}
value={value}
placeholder="센터명을 검색하세요"
onChange={(nextCode) => {
setValue(nextCode);
}}
/>
);
}

View File

@@ -0,0 +1,63 @@
import { Card, Flex, Typography } from 'antd';
import { useMemo, useState } from 'react';
import type { SampleMeta } from '../../../../widgets/core';
import { plugins } from '../../../../types/component-plugin';
import { createSelectPlaceholderPlugin, createSelectSortPlugin } from '../plugins';
import { SelectUI } from '../SelectUI';
import type { SelectOptionItem, SelectUIProps } from '../types';
const { Paragraph, Text } = Typography;
export const sampleMeta: SampleMeta = {
id: 'select-input',
componentId: 'select-input',
title: 'Select Input',
description: 'code/value 데이터를 받아 code를 값으로 유지하는 필터형 select combo 샘플입니다.',
category: 'Inputs',
kind: 'feature',
variantLabel: 'Showcase',
order: 41,
features: ['docs'],
};
export function Sample() {
const [selectedCode, setSelectedCode] = useState<string | undefined>('WH-001');
const data = useMemo<SelectOptionItem[]>(
() => [
{ code: 'WH-001', value: '서울 물류센터' },
{ code: 'WH-002', value: '김포 물류센터' },
{ code: 'WH-003', value: '부산 물류센터' },
{ code: 'WH-004', value: '대전 허브센터' },
],
[],
);
const selectProps = plugins<SelectUIProps>(
{
data,
value: selectedCode,
onChange: (nextCode) => {
setSelectedCode(nextCode);
},
},
[createSelectPlaceholderPlugin('센터명을 검색하세요'), createSelectSortPlugin()],
);
const selectedItem = data.find((item) => item.code === selectedCode);
return (
<Card title="Select Input Sample" extra={<Text code>samples/Sample.tsx</Text>}>
<Paragraph>
`value` <Text strong>code</Text> ,
<Text strong> value</Text> .
</Paragraph>
<Flex vertical gap="small">
<SelectUI {...selectProps} />
<Text> code: {selectedCode ?? '-'}</Text>
<Text> value: {selectedItem?.value ?? '-'}</Text>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1 @@
export type { SelectOptionItem, SelectUIProps } from './select';

View File

@@ -0,0 +1,16 @@
import type { SelectProps } from 'antd';
export type SelectOptionItem = {
code: string;
value: string;
};
export type SelectUIProps = Omit<
SelectProps<string>,
'options' | 'value' | 'defaultValue' | 'onChange'
> & {
data: SelectOptionItem[];
value?: string;
defaultValue?: string;
onChange?: (code?: string, item?: SelectOptionItem) => void;
};

View File

@@ -0,0 +1,16 @@
.button-editable-input {
width: 100%;
}
.button-editable-input__field--readonly.ant-input,
.button-editable-input__field--readonly.ant-input-affix-wrapper,
.button-editable-input__field--readonly.ant-input-outlined {
color: rgba(0, 0, 0, 0.88);
background-color: #f5f5f5;
}
.button-editable-input__button--readonly.ant-btn {
color: rgba(0, 0, 0, 0.88);
background-color: #f5f5f5;
border-color: #d9d9d9;
}

View File

@@ -0,0 +1,96 @@
import { Button, Flex } from 'antd';
import type { InputRef } from 'antd';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import './ButtonEditableInputUI.css';
import { InputUI } from '../../primitives/input';
import { createValidInputPlugin } from '../../primitives/input/plugins';
import type { InputCommitPlugin, InputUIProps, InputValidator } from '../../primitives/input';
export type ButtonEditableInputUIProps = Omit<
InputUIProps,
'commitPlugins' | 'readOnly'
> & {
onValid?: InputValidator;
commitPlugins?: ReadonlyArray<InputCommitPlugin>;
editButtonLabel?: string;
confirmButtonLabel?: string;
};
export const ButtonEditableInputUI = forwardRef<InputRef, ButtonEditableInputUIProps>(
function ButtonEditableInputUI(
{
onValid,
commitPlugins = [],
editButtonLabel = '수정',
confirmButtonLabel = '확인',
disabled,
onBlur,
onPressEnter,
...restProps
},
ref,
) {
const inputRef = useRef<InputRef>(null);
const [isEditing, setIsEditing] = useState(false);
useImperativeHandle(ref, () => inputRef.current as InputRef, []);
useEffect(() => {
if (!isEditing) {
return;
}
inputRef.current?.focus({
cursor: 'all',
});
}, [isEditing]);
const mergedCommitPlugins = onValid
? [createValidInputPlugin(onValid), ...commitPlugins]
: [...commitPlugins];
const inputClassName = [restProps.className, !isEditing ? 'button-editable-input__field--readonly' : '']
.filter(Boolean)
.join(' ');
return (
<Flex gap="small" align="center" className="button-editable-input">
<InputUI
{...restProps}
ref={inputRef}
className={inputClassName}
disabled={disabled}
readOnly={!isEditing}
commitPlugins={mergedCommitPlugins}
onBlur={(event) => {
setIsEditing(false);
onBlur?.(event);
}}
onPressEnter={(event) => {
setIsEditing(false);
onPressEnter?.(event);
}}
/>
<Button
disabled={disabled}
className={!isEditing ? 'button-editable-input__button--readonly' : undefined}
onMouseDown={(event) => {
if (isEditing) {
event.preventDefault();
}
}}
onClick={() => {
if (!isEditing) {
setIsEditing(true);
return;
}
setIsEditing(false);
inputRef.current?.blur();
}}
>
{isEditing ? confirmButtonLabel : editButtonLabel}
</Button>
</Flex>
);
},
);

View File

@@ -0,0 +1,2 @@
export { ButtonEditableInputUI } from './ButtonEditableInputUI';
export type { ButtonEditableInputUIProps } from './ButtonEditableInputUI';

View File

@@ -0,0 +1,28 @@
import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core';
import { ButtonEditableInputUI } from '../ButtonEditableInputUI';
export const sampleMeta: SampleMeta = {
id: 'button-editable-input-base',
componentId: 'button-editable-input',
title: 'Button Editable Input',
description: '버튼을 눌러 읽기 전용 입력값을 수정 가능한 상태로 전환하는 컴포넌트입니다.',
category: 'Inputs',
kind: 'base',
variantLabel: 'Base',
order: 50,
features: ['docs'],
};
export function Sample() {
const [value, setValue] = useState('운영팀 공용 메모');
return (
<ButtonEditableInputUI
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>
);
}

View File

@@ -0,0 +1,44 @@
import { Card, Flex, Typography } from 'antd';
import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core';
import { ButtonEditableInputUI } from '../ButtonEditableInputUI';
const { Paragraph, Text } = Typography;
export const sampleMeta: SampleMeta = {
id: 'button-editable-input',
componentId: 'button-editable-input',
title: 'Button Editable Input',
description:
'기본적으로 readonly 상태를 유지하다가 버튼 클릭 시에만 편집할 수 있고, 유효하지 않으면 이전 값으로 복원되는 입력 샘플입니다.',
category: 'Inputs',
kind: 'feature',
variantLabel: 'Showcase',
order: 50,
features: ['docs'],
};
export function Sample() {
const [postalCode, setPostalCode] = useState('04524');
return (
<Card title="Button Editable Input Sample" extra={<Text code>samples/Sample.tsx</Text>}>
<Paragraph>
, <Text strong>5 </Text> .
</Paragraph>
<Flex vertical gap="small">
<ButtonEditableInputUI
value={postalCode}
placeholder="우편번호 5자리"
onValid={({ nextValue }) => /^\d{5}$/.test(nextValue)}
onChange={(event) => {
setPostalCode(event.target.value);
}}
/>
<Text> : {postalCode}</Text>
<Text type="secondary"> readonly .</Text>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1,29 @@
import { forwardRef } from 'react';
import type { InputRef } from 'antd';
import { InputUI } from '../../primitives/input';
import { createEmailValidatorPlugin } from './plugins';
import type { InputUIProps } from '../../primitives/input';
export type EmailInputUIProps = InputUIProps;
export const EmailInputUI = forwardRef<InputRef, EmailInputUIProps>(function EmailInputUI(
{
commitPlugins = [],
inputMode = 'email',
placeholder = '이메일을 입력하세요',
autoComplete = 'email',
...restProps
},
ref,
) {
return (
<InputUI
{...restProps}
ref={ref}
inputMode={inputMode}
placeholder={placeholder}
autoComplete={autoComplete}
commitPlugins={[createEmailValidatorPlugin(), ...commitPlugins]}
/>
);
});

View File

@@ -0,0 +1,4 @@
export { EmailInputUI } from './EmailInputUI';
export type { EmailInputUIProps } from './EmailInputUI';
export * from './plugins';
export * from './types';

View File

@@ -0,0 +1,8 @@
import type { InputCommitPlugin } from '../../../primitives/input';
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const createEmailValidatorPlugin =
(): InputCommitPlugin =>
({ nextValue }) =>
emailPattern.test(nextValue.trim());

View File

@@ -0,0 +1 @@
export { createEmailValidatorPlugin } from './email-input.plugin';

View File

@@ -0,0 +1,28 @@
import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core';
import { EmailInputUI } from '../EmailInputUI';
export const sampleMeta: SampleMeta = {
id: 'email-input-base',
componentId: 'email-input',
title: 'Email Input',
description: '이메일 형식을 검증하는 입력 컴포넌트입니다.',
category: 'Inputs',
kind: 'base',
variantLabel: 'Base',
order: 40,
features: ['docs'],
};
export function Sample() {
const [value, setValue] = useState('ops@example.com');
return (
<EmailInputUI
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>
);
}

View File

@@ -0,0 +1,41 @@
import { Card, Flex, Typography } from 'antd';
import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core';
import { EmailInputUI } from '../EmailInputUI';
const { Paragraph, Text } = Typography;
export const sampleMeta: SampleMeta = {
id: 'email-input',
componentId: 'email-input',
title: 'Email Input',
description: '이메일 형식을 검증하는 기본형 이메일 입력 샘플입니다.',
category: 'Inputs',
kind: 'feature',
variantLabel: 'Showcase',
order: 40,
features: ['docs'],
};
export function Sample() {
const [email, setEmail] = useState('hello@example.com');
return (
<Card title="Email Input Sample" extra={<Text code>samples/Sample.tsx</Text>}>
<Paragraph>
<Text strong> </Text> .
</Paragraph>
<Flex vertical gap="small">
<EmailInputUI
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
<Text> : {email}</Text>
<Text type="secondary"> .</Text>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1 @@
export type EmailValue = string;

View File

@@ -0,0 +1 @@
export type { EmailValue } from './email-input';

Some files were not shown because too many files have changed in this diff Show More