Initial import

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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