Initial import
This commit is contained in:
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';
|
||||
Reference in New Issue
Block a user