Initial import
This commit is contained in:
41
src/components/common/InlineImage.tsx
Executable file
41
src/components/common/InlineImage.tsx
Executable 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
51
src/components/dashboard/multiProgress/MultiProgressUI.tsx
Executable file
51
src/components/dashboard/multiProgress/MultiProgressUI.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
2
src/components/dashboard/multiProgress/index.ts
Executable file
2
src/components/dashboard/multiProgress/index.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { MultiProgressUI } from './MultiProgressUI';
|
||||
export type { MultiProgressItem, MultiProgressUIProps } from './MultiProgressUI';
|
||||
1
src/components/dashboard/multiProgress/plugins/index.ts
Executable file
1
src/components/dashboard/multiProgress/plugins/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { createMultiProgressMetaPlugin, createMultiProgressSortPlugin } from './multi-progress.plugin';
|
||||
16
src/components/dashboard/multiProgress/plugins/multi-progress.plugin.ts
Executable file
16
src/components/dashboard/multiProgress/plugins/multi-progress.plugin.ts
Executable 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),
|
||||
});
|
||||
}
|
||||
28
src/components/dashboard/multiProgress/samples/BaseSample.tsx
Executable file
28
src/components/dashboard/multiProgress/samples/BaseSample.tsx
Executable 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' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
src/components/dashboard/multiProgress/samples/Sample.tsx
Executable file
36
src/components/dashboard/multiProgress/samples/Sample.tsx
Executable 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} />;
|
||||
}
|
||||
1
src/components/dashboard/multiProgress/types/index.ts
Executable file
1
src/components/dashboard/multiProgress/types/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type { MultiProgressItem, MultiProgressUIProps } from './multi-progress';
|
||||
11
src/components/dashboard/multiProgress/types/multi-progress.ts
Executable file
11
src/components/dashboard/multiProgress/types/multi-progress.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
export type MultiProgressItem = {
|
||||
label: string;
|
||||
percent: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type MultiProgressUIProps = {
|
||||
label: string;
|
||||
meta?: string;
|
||||
data: MultiProgressItem[];
|
||||
};
|
||||
26
src/components/dashboard/progress/ProgressUI.tsx
Executable file
26
src/components/dashboard/progress/ProgressUI.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
3
src/components/dashboard/progress/index.ts
Executable file
3
src/components/dashboard/progress/index.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export { ProgressUI } from './ProgressUI';
|
||||
export { createProgressColorPlugin, createProgressMetaPlugin } from './plugins';
|
||||
export type { ProgressUIData, ProgressUIProps } from './types';
|
||||
1
src/components/dashboard/progress/plugins/index.ts
Executable file
1
src/components/dashboard/progress/plugins/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { createProgressColorPlugin, createProgressMetaPlugin } from './progress.plugin';
|
||||
19
src/components/dashboard/progress/plugins/progress.plugin.ts
Executable file
19
src/components/dashboard/progress/plugins/progress.plugin.ts
Executable 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
27
src/components/dashboard/progress/samples/BaseSample.tsx
Executable file
27
src/components/dashboard/progress/samples/BaseSample.tsx
Executable 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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
src/components/dashboard/progress/samples/Sample.tsx
Executable file
32
src/components/dashboard/progress/samples/Sample.tsx
Executable 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} />;
|
||||
}
|
||||
1
src/components/dashboard/progress/types/index.ts
Executable file
1
src/components/dashboard/progress/types/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type { ProgressUIData, ProgressUIProps } from './progress';
|
||||
10
src/components/dashboard/progress/types/progress.ts
Executable file
10
src/components/dashboard/progress/types/progress.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
export type ProgressUIData = {
|
||||
percent: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type ProgressUIProps = {
|
||||
label: string;
|
||||
meta?: string;
|
||||
data: ProgressUIData;
|
||||
};
|
||||
42
src/components/dataListTable/DataListTable.css
Executable file
42
src/components/dataListTable/DataListTable.css
Executable 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%;
|
||||
}
|
||||
}
|
||||
204
src/components/dataListTable/DataListTable.tsx
Executable file
204
src/components/dataListTable/DataListTable.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
src/components/dataListTable/index.ts
Executable file
1
src/components/dataListTable/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export * from './DataListTable';
|
||||
97
src/components/dataListTable/samples/BaseSample.tsx
Executable file
97
src/components/dataListTable/samples/BaseSample.tsx
Executable 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
162
src/components/dataStatePanel/DataStatePanel.css
Executable file
162
src/components/dataStatePanel/DataStatePanel.css
Executable 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;
|
||||
}
|
||||
}
|
||||
158
src/components/dataStatePanel/DataStatePanel.tsx
Executable file
158
src/components/dataStatePanel/DataStatePanel.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
2
src/components/dataStatePanel/index.ts
Executable file
2
src/components/dataStatePanel/index.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { DataStatePanel } from './DataStatePanel';
|
||||
export type { DataStatePanelProps, DataStatePanelStatus } from './DataStatePanel';
|
||||
26
src/components/dataStatePanel/samples/BaseSample.css
Executable file
26
src/components/dataStatePanel/samples/BaseSample.css
Executable 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);
|
||||
}
|
||||
}
|
||||
131
src/components/dataStatePanel/samples/BaseSample.tsx
Executable file
131
src/components/dataStatePanel/samples/BaseSample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
154
src/components/embeddedMap/EmbeddedMapUI.css
Executable file
154
src/components/embeddedMap/EmbeddedMapUI.css
Executable 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;
|
||||
}
|
||||
}
|
||||
247
src/components/embeddedMap/EmbeddedMapUI.tsx
Executable file
247
src/components/embeddedMap/EmbeddedMapUI.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
2
src/components/embeddedMap/index.ts
Executable file
2
src/components/embeddedMap/index.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { EmbeddedMapUI } from './EmbeddedMapUI';
|
||||
export type { EmbeddedMapUIProps } from './EmbeddedMapUI';
|
||||
29
src/components/embeddedMap/samples/BaseSample.tsx
Executable file
29
src/components/embeddedMap/samples/BaseSample.tsx
Executable 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="출발"
|
||||
/>
|
||||
);
|
||||
}
|
||||
52
src/components/embeddedMap/samples/Sample.tsx
Executable file
52
src/components/embeddedMap/samples/Sample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
131
src/components/emptyIllustrationCard/EmptyIllustrationCard.css
Executable file
131
src/components/emptyIllustrationCard/EmptyIllustrationCard.css
Executable 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);
|
||||
}
|
||||
}
|
||||
84
src/components/emptyIllustrationCard/EmptyIllustrationCard.tsx
Executable file
84
src/components/emptyIllustrationCard/EmptyIllustrationCard.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
src/components/emptyIllustrationCard/index.ts
Executable file
1
src/components/emptyIllustrationCard/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export * from './EmptyIllustrationCard';
|
||||
34
src/components/emptyIllustrationCard/samples/BaseSample.tsx
Executable file
34
src/components/emptyIllustrationCard/samples/BaseSample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
173
src/components/evidenceAttachmentStrip/EvidenceAttachmentStrip.css
Executable file
173
src/components/evidenceAttachmentStrip/EvidenceAttachmentStrip.css
Executable 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);
|
||||
}
|
||||
}
|
||||
377
src/components/evidenceAttachmentStrip/EvidenceAttachmentStrip.tsx
Executable file
377
src/components/evidenceAttachmentStrip/EvidenceAttachmentStrip.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
8
src/components/evidenceAttachmentStrip/index.ts
Executable file
8
src/components/evidenceAttachmentStrip/index.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
export { EvidenceAttachmentPreviewBody, EvidenceAttachmentStrip } from './EvidenceAttachmentStrip';
|
||||
export type {
|
||||
EvidenceAttachmentActionHandler,
|
||||
EvidenceAttachmentItem,
|
||||
EvidenceAttachmentKind,
|
||||
EvidenceAttachmentPreviewBodyProps,
|
||||
EvidenceAttachmentStripProps,
|
||||
} from './types';
|
||||
75
src/components/evidenceAttachmentStrip/samples/BaseSample.tsx
Executable file
75
src/components/evidenceAttachmentStrip/samples/BaseSample.tsx
Executable 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
144
src/components/evidenceAttachmentStrip/samples/Sample.tsx
Executable file
144
src/components/evidenceAttachmentStrip/samples/Sample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
49
src/components/evidenceAttachmentStrip/types/evidence-attachment-strip.ts
Executable file
49
src/components/evidenceAttachmentStrip/types/evidence-attachment-strip.ts
Executable 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;
|
||||
};
|
||||
7
src/components/evidenceAttachmentStrip/types/index.ts
Executable file
7
src/components/evidenceAttachmentStrip/types/index.ts
Executable file
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
EvidenceAttachmentActionHandler,
|
||||
EvidenceAttachmentItem,
|
||||
EvidenceAttachmentKind,
|
||||
EvidenceAttachmentPreviewBodyProps,
|
||||
EvidenceAttachmentStripProps,
|
||||
} from './evidence-attachment-strip';
|
||||
7
src/components/formField/FormField.css
Executable file
7
src/components/formField/FormField.css
Executable file
@@ -0,0 +1,7 @@
|
||||
.form-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-field--disabled {
|
||||
opacity: 0.72;
|
||||
}
|
||||
89
src/components/formField/FormField.tsx
Executable file
89
src/components/formField/FormField.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
src/components/formField/index.ts
Executable file
1
src/components/formField/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export * from './FormField';
|
||||
49
src/components/formField/samples/BaseSample.tsx
Executable file
49
src/components/formField/samples/BaseSample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
78
src/components/inputs/checkCombo/CheckComboUI.tsx
Executable file
78
src/components/inputs/checkCombo/CheckComboUI.tsx
Executable 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),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
6
src/components/inputs/checkCombo/index.ts
Executable file
6
src/components/inputs/checkCombo/index.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
export { CheckComboUI } from './CheckComboUI';
|
||||
export {
|
||||
createCheckComboPlaceholderPlugin,
|
||||
createCheckComboSortPlugin,
|
||||
} from './plugins';
|
||||
export type { CheckComboUIProps, SelectOptionItem } from './types';
|
||||
18
src/components/inputs/checkCombo/plugins/check-combo.plugin.ts
Executable file
18
src/components/inputs/checkCombo/plugins/check-combo.plugin.ts
Executable 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)),
|
||||
});
|
||||
}
|
||||
4
src/components/inputs/checkCombo/plugins/index.ts
Executable file
4
src/components/inputs/checkCombo/plugins/index.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createCheckComboPlaceholderPlugin,
|
||||
createCheckComboSortPlugin,
|
||||
} from './check-combo.plugin';
|
||||
37
src/components/inputs/checkCombo/samples/BaseSample.tsx
Executable file
37
src/components/inputs/checkCombo/samples/BaseSample.tsx
Executable 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
72
src/components/inputs/checkCombo/samples/Sample.tsx
Executable file
72
src/components/inputs/checkCombo/samples/Sample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
16
src/components/inputs/checkCombo/types/check-combo.ts
Executable file
16
src/components/inputs/checkCombo/types/check-combo.ts
Executable 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;
|
||||
};
|
||||
1
src/components/inputs/checkCombo/types/index.ts
Executable file
1
src/components/inputs/checkCombo/types/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type { CheckComboUIProps, SelectOptionItem } from './check-combo';
|
||||
111
src/components/inputs/composite/multiInput/MultiInputUI.tsx
Executable file
111
src/components/inputs/composite/multiInput/MultiInputUI.tsx
Executable 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>
|
||||
);
|
||||
});
|
||||
4
src/components/inputs/composite/multiInput/index.ts
Executable file
4
src/components/inputs/composite/multiInput/index.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export { MultiInputUI } from './MultiInputUI';
|
||||
export type { MultiInputUIProps } from './MultiInputUI';
|
||||
export * from './plugins';
|
||||
export * from './types';
|
||||
4
src/components/inputs/composite/multiInput/plugins/index.ts
Executable file
4
src/components/inputs/composite/multiInput/plugins/index.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createMultiInputValidatorPlugin,
|
||||
createMultiSegmentValidatorPlugin,
|
||||
} from './multi-input.plugin';
|
||||
15
src/components/inputs/composite/multiInput/plugins/multi-input.plugin.ts
Executable file
15
src/components/inputs/composite/multiInput/plugins/multi-input.plugin.ts
Executable 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;
|
||||
};
|
||||
28
src/components/inputs/composite/multiInput/samples/BaseSample.tsx
Executable file
28
src/components/inputs/composite/multiInput/samples/BaseSample.tsx
Executable 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
src/components/inputs/composite/multiInput/samples/Sample.tsx
Executable file
41
src/components/inputs/composite/multiInput/samples/Sample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
src/components/inputs/composite/multiInput/types/index.ts
Executable file
1
src/components/inputs/composite/multiInput/types/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type { MultiInputParts, MultiInputValue } from './multi-input';
|
||||
2
src/components/inputs/composite/multiInput/types/multi-input.ts
Executable file
2
src/components/inputs/composite/multiInput/types/multi-input.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export type MultiInputValue = string;
|
||||
export type MultiInputParts = [string, string, string];
|
||||
66
src/components/inputs/popup/PopupUI.tsx
Executable file
66
src/components/inputs/popup/PopupUI.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
6
src/components/inputs/popup/index.ts
Executable file
6
src/components/inputs/popup/index.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
export { PopupUI } from './PopupUI';
|
||||
export {
|
||||
createPopupButtonTextPlugin,
|
||||
createPopupResultPlaceholderPlugin,
|
||||
} from './plugins';
|
||||
export type { PopupUIProps } from './types';
|
||||
4
src/components/inputs/popup/plugins/index.ts
Executable file
4
src/components/inputs/popup/plugins/index.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createPopupButtonTextPlugin,
|
||||
createPopupResultPlaceholderPlugin,
|
||||
} from './popup.plugin';
|
||||
18
src/components/inputs/popup/plugins/popup.plugin.ts
Executable file
18
src/components/inputs/popup/plugins/popup.plugin.ts
Executable 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,
|
||||
});
|
||||
}
|
||||
35
src/components/inputs/popup/samples/BaseSample.tsx
Executable file
35
src/components/inputs/popup/samples/BaseSample.tsx
Executable 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} 선택 결과` : '선택 결과 없음');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
60
src/components/inputs/popup/samples/Sample.tsx
Executable file
60
src/components/inputs/popup/samples/Sample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
src/components/inputs/popup/types/index.ts
Executable file
1
src/components/inputs/popup/types/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type { PopupUIProps } from './popup';
|
||||
19
src/components/inputs/popup/types/popup.ts
Executable file
19
src/components/inputs/popup/types/popup.ts
Executable 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;
|
||||
};
|
||||
88
src/components/inputs/primitives/input/InputUI.tsx
Executable file
88
src/components/inputs/primitives/input/InputUI.tsx
Executable 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
4
src/components/inputs/primitives/input/index.ts
Executable file
4
src/components/inputs/primitives/input/index.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export { InputUI } from './InputUI';
|
||||
export type { InputUIProps, InputUIProps as DeferredInputProps } from './InputUI';
|
||||
export * from './plugins';
|
||||
export * from './types';
|
||||
1
src/components/inputs/primitives/input/plugins/index.ts
Executable file
1
src/components/inputs/primitives/input/plugins/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { createValidInputPlugin, trimInputValuePlugin } from './input.plugin';
|
||||
22
src/components/inputs/primitives/input/plugins/input.plugin.ts
Executable file
22
src/components/inputs/primitives/input/plugins/input.plugin.ts
Executable 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,
|
||||
});
|
||||
29
src/components/inputs/primitives/input/samples/BaseSample.tsx
Executable file
29
src/components/inputs/primitives/input/samples/BaseSample.tsx
Executable 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
47
src/components/inputs/primitives/input/samples/Sample.tsx
Executable file
47
src/components/inputs/primitives/input/samples/Sample.tsx
Executable 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;
|
||||
63
src/components/inputs/primitives/input/samples/ValidInputSample.tsx
Executable file
63
src/components/inputs/primitives/input/samples/ValidInputSample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
6
src/components/inputs/primitives/input/types/index.ts
Executable file
6
src/components/inputs/primitives/input/types/index.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
InputCommitContext,
|
||||
InputCommitPlugin,
|
||||
InputCommitTiming,
|
||||
InputValidator,
|
||||
} from './input';
|
||||
11
src/components/inputs/primitives/input/types/input.ts
Executable file
11
src/components/inputs/primitives/input/types/input.ts
Executable 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;
|
||||
54
src/components/inputs/select/SelectUI.tsx
Executable file
54
src/components/inputs/select/SelectUI.tsx
Executable 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
3
src/components/inputs/select/index.ts
Executable file
3
src/components/inputs/select/index.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export { SelectUI } from './SelectUI';
|
||||
export { createSelectPlaceholderPlugin, createSelectSortPlugin } from './plugins';
|
||||
export type { SelectOptionItem, SelectUIProps } from './types';
|
||||
1
src/components/inputs/select/plugins/index.ts
Executable file
1
src/components/inputs/select/plugins/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { createSelectPlaceholderPlugin, createSelectSortPlugin } from './select.plugin';
|
||||
16
src/components/inputs/select/plugins/select.plugin.ts
Executable file
16
src/components/inputs/select/plugins/select.plugin.ts
Executable 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)),
|
||||
});
|
||||
}
|
||||
37
src/components/inputs/select/samples/BaseSample.tsx
Executable file
37
src/components/inputs/select/samples/BaseSample.tsx
Executable 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
63
src/components/inputs/select/samples/Sample.tsx
Executable file
63
src/components/inputs/select/samples/Sample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
src/components/inputs/select/types/index.ts
Executable file
1
src/components/inputs/select/types/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type { SelectOptionItem, SelectUIProps } from './select';
|
||||
16
src/components/inputs/select/types/select.ts
Executable file
16
src/components/inputs/select/types/select.ts
Executable 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
2
src/components/inputs/specialized/buttonEditableInput/index.ts
Executable file
2
src/components/inputs/specialized/buttonEditableInput/index.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { ButtonEditableInputUI } from './ButtonEditableInputUI';
|
||||
export type { ButtonEditableInputUIProps } from './ButtonEditableInputUI';
|
||||
28
src/components/inputs/specialized/buttonEditableInput/samples/BaseSample.tsx
Executable file
28
src/components/inputs/specialized/buttonEditableInput/samples/BaseSample.tsx
Executable 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
src/components/inputs/specialized/buttonEditableInput/samples/Sample.tsx
Executable file
44
src/components/inputs/specialized/buttonEditableInput/samples/Sample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
29
src/components/inputs/specialized/emailInput/EmailInputUI.tsx
Executable file
29
src/components/inputs/specialized/emailInput/EmailInputUI.tsx
Executable 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]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
4
src/components/inputs/specialized/emailInput/index.ts
Executable file
4
src/components/inputs/specialized/emailInput/index.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export { EmailInputUI } from './EmailInputUI';
|
||||
export type { EmailInputUIProps } from './EmailInputUI';
|
||||
export * from './plugins';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { InputCommitPlugin } from '../../../primitives/input';
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export const createEmailValidatorPlugin =
|
||||
(): InputCommitPlugin =>
|
||||
({ nextValue }) =>
|
||||
emailPattern.test(nextValue.trim());
|
||||
1
src/components/inputs/specialized/emailInput/plugins/index.ts
Executable file
1
src/components/inputs/specialized/emailInput/plugins/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { createEmailValidatorPlugin } from './email-input.plugin';
|
||||
28
src/components/inputs/specialized/emailInput/samples/BaseSample.tsx
Executable file
28
src/components/inputs/specialized/emailInput/samples/BaseSample.tsx
Executable 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
src/components/inputs/specialized/emailInput/samples/Sample.tsx
Executable file
41
src/components/inputs/specialized/emailInput/samples/Sample.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
src/components/inputs/specialized/emailInput/types/email-input.ts
Executable file
1
src/components/inputs/specialized/emailInput/types/email-input.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type EmailValue = string;
|
||||
1
src/components/inputs/specialized/emailInput/types/index.ts
Executable file
1
src/components/inputs/specialized/emailInput/types/index.ts
Executable 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
Reference in New Issue
Block a user