Combobox
Data Input
키워드를 입력해 결과를 빠르게 찾는 검색 입력.
Usage
선택지가 많아 검색으로 후보를 좁히거나 직접 입력과 선택을 함께 제공할 때 사용한다.
import
import
import { Combobox } from '@mildang/design-system/Combobox';Combobox 하나만 가져오면 Combobox.Option · Combobox.OptionIcon · Combobox.OptionLabel · Combobox.OptionDescription · Combobox.Root · Combobox.Control · Combobox.Trigger · Combobox.Positioner · Combobox.Content · Combobox.ContentScrollWrapper · Combobox.ItemGroup · Combobox.ItemGroupLabel · Combobox.Separator · Combobox.Item · Combobox.ItemIndicator · Combobox.Empty 를 그 아래에서 쓸 수 있다.
예제를 복사해 쓸 때 필요한 준비
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
• 외부 패키지를 따로 설치한다. DS 의 전이 의존성에 기대지 않는다.
Anatomy
tsx
import { Combobox } from '@mildang/design-system/Combobox';
export default function Example() {
return (
<Combobox>
<Combobox.Control>
<Combobox.Trigger />
</Combobox.Control>
<Combobox.Content>
<Combobox.ItemGroup>
<Combobox.Item />
</Combobox.ItemGroup>
<Combobox.Empty />
</Combobox.Content>
</Combobox>
);
}부품
필수 여부
반복
위치
Combobox.Root
Combobox.Control
Root 안에 둔다.
Combobox.Trigger
Control 안에 둔다.
Combobox.Content
Root 안에 둔다.
Combobox.ItemGroup
여러 개 가능
Content 안에 둔다.
Combobox.Item
여러 개 가능
Content 또는 ItemGroup 안에 둔다.
Combobox.Empty
Content 안에 둔다.
- Root 안에 Input과 Content를 구성하고, Content 안에 선택 항목을 반복 배치한다.
API Reference
Combobox Props
Prop
Type
Default
string
지정 안 함
boolean & import("@mildang/styled-system/types").ConditionalValue<boolean>
false
boolean
지정 안 함
boolean
지정 안 함
boolean
지정 안 함
"sm" | "md" | "lg" | "xl"
md
boolean
지정 안 함
boolean
지정 안 함
Combobox.Control
입력과 트리거를 감싸는 컨트롤 영역이다.
공개 Props 없음
Combobox.Trigger
옵션 목록을 여닫는 트리거다.
공개 Props 없음
Combobox.Content
검색 결과 목록을 표시하는 콘텐츠다.
공개 Props 없음
Combobox.ItemGroup
검색 결과를 그룹으로 묶는다.
공개 Props 없음
Combobox.Item
선택 가능한 검색 결과 항목이다.
공개 Props 없음
Combobox.Empty
검색 결과가 없을 때 표시한다.
공개 Props 없음
같은 패밀리
@mildang/design-system/Combobox 에서 같이 내보내는 컴포넌트다.
옵션 콘텐츠 구성과 상태
Combobox.Option(ComboboxOptionComponent)은 Menu·Select 와 같은 content_type 을 지원한다.
Menu 는 정적, Combobox 는 검색+필터가 붙는 차이만 있고 아이템 모양은 통일된다.
| Figma content_type | 사용 prop |
|---|---|
| text_only | children |
| icon_left | startAdornment (아이콘) |
| with_avatar | startAdornment (Avatar 컴포넌트) |
| with_avatar_caption | startAdornment + caption |
| bottom_description | description (기본, 라벨 아래) |
| right_description | description + descriptionPosition: 'right' |
| chip | children 슬롯에 Chip 컴포넌트 삽입 |
caption 이 배열이면 항목 사이에 divider(2px 원형)가 자동 삽입된다.
옵션 상태는 두 갈래다.
selected— Ark Combobox 컨텍스트가 현재 선택값 기준으로 자동 반영한다.active— prop 으로 직접 켠다. 현재 컨텍스트(예: 로그인한 사용자, 열린 채팅방)를 강조하는 지속 상태이고 CSS:active와는 무관하다.
Select 와 고르는 기준은 검색 가능 여부가 아니다 — Select 문서의 select-vs-combobox 섹션을 참고한다.
콘텐츠 타입
아이콘·아바타+caption·description(아래/오른쪽)·active 를 한 목록에 섞습니다 — Figma content_type 이 옵션 prop 으로 대응되는 모습입니다.
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
import { renderChipTag } from '@mildang/design-system/Combobox';
import { Chip } from '@mildang/design-system/Chip';
import { type ChipVariantProps } from '@mildang/styled-system/recipes';
// ============================================================================
// Chip 옵션 스토리
// ============================================================================
type ChipItem = {
label: string;
value: string;
color: NonNullable<ChipVariantProps['color']>;
};
const chipStatusItems: ChipItem[] = [
{ label: '완료', value: 'done', color: 'green' },
{ label: '진행 중', value: 'in_progress', color: 'blue' },
{ label: '대기', value: 'pending', color: 'orange' },
{ label: '오류', value: 'error', color: 'red' },
];
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '상태를 선택하세요',
}) } as ComponentProps<typeof Combobox>;
const ComboboxChipOptionsWithHelperExampleRender = (args: ComponentProps<typeof Combobox>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: chipStatusItems,
itemToString: (item: ChipItem) => item.label,
itemToValue: (item: ChipItem) => item.value,
filter: (_itemText: string, filterText: string, item: ChipItem) => contains(item.label, filterText),
});
return (
<Combobox
{...args}
multiple
collection={collection}
filter={filter}
// items가 { label, color } 규약을 따르면 renderChipTag helper로 한 줄 처리
renderTag={renderChipTag}
>
{(collection.items || []).map((item: ChipItem) => (
<Combobox.Option key={item.value} item={item.value}>
<Chip label={item.label} color={item.color} type="text" size="sm" />
</Combobox.Option>
))}
</Combobox>
);
};
export default function ComboboxChipOptionsWithHelperExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxChipOptionsWithHelperExampleRender(mergedProps);
}
선택 값을 Chip 으로 표시
multiple 모드에서 선택된 값을 입력창(트리거)에 chip 으로 보여주려면 renderTag 에
renderChipTag 를 넘긴다. item 이 { label, color } 규약을 따르면 자동으로 Chip 으로 렌더한다.
renderTag— 트리거의 선택된 값 렌더. menu 안 아이템은Combobox.Optionchildren 으로 Chip 을 직접 넣는다(Figmacontent_type=chip). 두 곳의 label/color 가 자동으로 일치한다.- item 규약이 다르거나 커스텀 렌더가 필요하면
renderTag에 직접 함수를 넘기면 된다.
단일 모드(multiple=false)는 트리거가 Input 이라 chip 을 렌더할 자리가 없다. Select 처럼
트리거에 chip 하나만 노출하려면 singleChip 을 쓴다 — 내부적으로 multiple + max=1 + _closeOnSelect 로 위임되어 TagsInput 이 chip 1개만 허용하는 형태로 렌더된다.
Select 의 renderChipValues 와 대칭 API다 — 어느 쪽이든 같은 { label, color } 규약만
지키면 동일하게 쓴다. color 는 brand/neutral/green/orange/blue/red 만 유효하다.
값을 Chip 으로 표시
multiple + renderTag={renderChipTag} 로 트리거와 옵션 모두 chip 으로, singleChip 으로 단일 선택도 chip 하나로 표시합니다.
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
import { Chip } from '@mildang/design-system/Chip';
import { type ChipVariantProps } from '@mildang/styled-system/recipes';
// ============================================================================
// Chip 옵션 스토리
// ============================================================================
type ChipItem = {
label: string;
value: string;
color: NonNullable<ChipVariantProps['color']>;
};
const chipStatusItems: ChipItem[] = [
{ label: '완료', value: 'done', color: 'green' },
{ label: '진행 중', value: 'in_progress', color: 'blue' },
{ label: '대기', value: 'pending', color: 'orange' },
{ label: '오류', value: 'error', color: 'red' },
];
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '상태를 선택하세요',
}) } as ComponentProps<typeof Combobox>;
const ComboboxChipOptionsExampleRender = (args: ComponentProps<typeof Combobox>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: chipStatusItems,
itemToString: (item: ChipItem) => item.label,
itemToValue: (item: ChipItem) => item.value,
filter: (_itemText: string, filterText: string, item: ChipItem) => contains(item.label, filterText),
});
return (
<Combobox
{...args}
multiple
collection={collection}
filter={filter}
// 트리거 chip 렌더: item.label / item.color 그대로 사용
renderTag={({ item, onDelete }) =>
item ? (
<Chip label={item.label} color={item.color} onDelete={onDelete} type="text" size="sm" />
) : null
}
>
{(collection.items || []).map((item: ChipItem) => (
<Combobox.Option key={item.value} item={item.value}>
<Chip label={item.label} color={item.color} type="text" size="sm" />
</Combobox.Option>
))}
</Combobox>
);
};
export default function ComboboxChipOptionsExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxChipOptionsExampleRender(mergedProps);
}
예제
기본 사용
useListCollection 과 useFilter 로 검색되는 목록을 만든 최소 형태 — 옵션이 많아 타이핑으로 좁혀야 할 때 씁니다.
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const sampleItems: StoryItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte' },
{ label: 'Next.js', value: 'nextjs', optionlabel: 'Next.js' },
{ label: 'Nuxt.js', value: 'nuxtjs', optionlabel: 'Nuxt.js' },
{ label: 'SvelteKit', value: 'sveltekit', optionlabel: 'SvelteKit' },
{ label: 'Remix', value: 'remix', optionlabel: 'Remix' },
{ label: 'Astro', value: 'astro', optionlabel: 'Astro' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid' },
];
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '프레임워크를 검색하세요',
}) } as ComponentProps<typeof Combobox>;
const ComboboxDefaultExampleRender = (args: ComponentProps<typeof Combobox>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: sampleItems,
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
filter: (_itemText: string, filterText: string, item: StoryItem) => contains(item.label, filterText),
});
return (
<Combobox {...args} collection={collection} filter={filter}>
{(collection.items || []).map((item: StoryItem) => {
return (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
{item.description && (
<Combobox.OptionDescription>{item.description}</Combobox.OptionDescription>
)}
</Combobox.Option>
);
})}
</Combobox>
);
};
export default function ComboboxDefaultExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxDefaultExampleRender(mergedProps);
}
열린 상태
입력과 그 아래 열린 옵션 목록을 함께 고정해 보여줍니다 — 닫힌 입력만으로는 검색해서 고르는 물건임이 드러나지 않습니다.
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useListCollection } from '@ark-ui/react';
// EmptyItems 스토리를 위한 컴포넌트 (hook 사용을 위해)
const EmptyItemsCombobox = (args: Partial<ComboboxProps<string>>) => {
const { collection } = useListCollection<string>({
initialItems: [],
itemToString: () => '',
itemToValue: () => '',
});
return <Combobox {...args} collection={collection} placeholder="프레임워크를 검색하세요" />;
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '프레임워크를 검색하세요',
}) } as ComponentProps<typeof Combobox>;
const ComboboxEmptyItemsExampleRender = (args: ComponentProps<typeof Combobox>) => <EmptyItemsCombobox {...args} />;
export default function ComboboxEmptyItemsExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxEmptyItemsExampleRender(mergedProps);
}
크기
sm·md·lg·xl 의 입력 높이를 비교합니다. 같은 폼 안의 다른 입력과 같은 size 로 맞춥니다.
코드
import { useFilter, useListCollection } from '@ark-ui/react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { renderChipTag } from '@mildang/design-system/Combobox';
import { Chip } from '@mildang/design-system/Chip';
import React from 'react';
import { ChipVariantProps } from '@mildang/styled-system/recipes';
type SizesChipItem = {
label: string;
value: string;
optionlabel: string;
color: NonNullable<ChipVariantProps['color']>;
};
// Size 매트릭스 스토리 전용 — Basic 은 label 텍스트, Chip 은 <Chip color> 로 렌더.
const sizesChipItems: SizesChipItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React', color: 'blue' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue', color: 'green' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte', color: 'orange' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular', color: 'red' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid', color: 'brand' },
];
const SIZES_ORDER = ['sm', 'md', 'lg', 'xl'] as const;
type SizesMatrixVariant = {
label: string;
render: (size: (typeof SIZES_ORDER)[number]) => React.ReactNode;
};
const SizesMatrixSection = ({ title, variants }: { title: string; variants: SizesMatrixVariant[] }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontSize: '14px', fontWeight: 700, color: '#1f2933' }}>{title}</div>
<div
style={{
display: 'grid',
gridTemplateColumns: `80px repeat(${variants.length}, 320px)`,
columnGap: '16px',
rowGap: '12px',
alignItems: 'center',
}}
>
<div />
{variants.map((variant) => (
<div key={variant.label} style={{ fontSize: '12px', fontWeight: 500, color: '#4d5358' }}>
{variant.label}
</div>
))}
{SIZES_ORDER.map((size) => (
<React.Fragment key={size}>
<div style={{ fontSize: '12px', color: '#878d96' }}>size={size}</div>
{variants.map((variant) => (
<div key={`${size}-${variant.label}`}>{variant.render(size)}</div>
))}
</React.Fragment>
))}
</div>
</div>
);
const SizesMatrixCombobox = () => {
const { contains } = useFilter({ sensitivity: 'base' });
// Basic 과 Chip 섹션이 동일한 드롭다운 옵션을 사용하도록 sizesChipItems 를 공유.
// Basic 은 label 텍스트만 렌더하고, Chip 은 <Chip label color /> 을 렌더.
const shared = useListCollection({
initialItems: sizesChipItems,
itemToString: (item: SizesChipItem) => item.label,
itemToValue: (item: SizesChipItem) => item.value,
filter: (_itemText: string, filterText: string, item: SizesChipItem) => contains(item.label, filterText),
});
const basicVariants: SizesMatrixVariant[] = [
{
label: 'Single',
render: (size) => (
<Combobox
collection={shared.collection}
filter={shared.filter}
size={size}
placeholder={size}
fullWidth
>
{(shared.collection.items || []).map((item: SizesChipItem) => (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
</Combobox.Option>
))}
</Combobox>
),
},
{
label: 'Multiple',
render: (size) => (
<Combobox
collection={shared.collection}
filter={shared.filter}
size={size}
multiple
showChip
placeholder={size}
fullWidth
>
{(shared.collection.items || []).map((item: SizesChipItem) => (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
</Combobox.Option>
))}
</Combobox>
),
},
];
// Chip 노출은 `renderTag` 로 통일. single/multiple 은 `singleChip` / `multiple` prop 으로 구분.
// closeOnSelect 는 Combobox 가 모드에 맞는 기본값(single→true, multiple→false)을 자동 적용.
const renderChipCombobox = (size: (typeof SIZES_ORDER)[number], mode: 'single' | 'multiple') => (
<Combobox
collection={shared.collection}
filter={shared.filter}
size={size}
singleChip={mode === 'single'}
multiple={mode === 'multiple'}
placeholder={size}
fullWidth
renderTag={renderChipTag}
>
{(shared.collection.items || []).map((item: SizesChipItem) => (
<Combobox.Option key={item.value} item={item.value}>
<Chip label={item.label} color={item.color} type="text" size="sm" />
</Combobox.Option>
))}
</Combobox>
);
const chipVariants: SizesMatrixVariant[] = [
{ label: 'Chip · Single', render: (size) => renderChipCombobox(size, 'single') },
{ label: 'Chip · Multiple', render: (size) => renderChipCombobox(size, 'multiple') },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<SizesMatrixSection title="Basic" variants={basicVariants} />
<SizesMatrixSection title="Chip" variants={chipVariants} />
</div>
);
};
const ComboboxSizesExample = () => <SizesMatrixCombobox />;
export default ComboboxSizesExample;
LongLabel
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
const STORY_DEFAULT_ARGS = { ...({}), ...({}) } as ComponentProps<typeof Combobox>;
const ComboboxLongLabelExampleRender = (args: ComponentProps<typeof Combobox>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: [
{ label: '짧은 옵션', value: 'short' },
{ label: '아주 길고 긴 옵션 라벨 텍스트 예시입니다 1234567890 abcdefg', value: 'long' },
{
label:
'아주 아주 아주 아주 아주 아주 길고 긴 옵션 라벨 텍스트 예시입니다 1234567890 abcdefg 1234567890 abcdefg 1234567890 abcdefg',
value: 'very_long',
},
{ label: '중간 길이 옵션 라벨', value: 'medium' },
],
itemToString: (item: { label: string; value: string }) => item.label,
itemToValue: (item: { label: string; value: string }) => item.value,
filter: (_t: string, f: string, item: { label: string; value: string }) => contains(item.label, f),
});
// 기본 너비(380px)를 그대로 보여준다. 긴 라벨은 2줄 clamp + ellipsis (드롭다운은 늘어나지 않음).
return (
<Combobox {...args} collection={collection} filter={filter} placeholder="검색">
{(collection.items || []).map((item: { label: string; value: string }) => (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
</Combobox.Option>
))}
</Combobox>
);
};
export default function ComboboxLongLabelExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxLongLabelExampleRender(mergedProps);
}
State
코드
import { useFilter, useListCollection } from '@ark-ui/react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const sampleItems: StoryItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte' },
{ label: 'Next.js', value: 'nextjs', optionlabel: 'Next.js' },
{ label: 'Nuxt.js', value: 'nuxtjs', optionlabel: 'Nuxt.js' },
{ label: 'SvelteKit', value: 'sveltekit', optionlabel: 'SvelteKit' },
{ label: 'Remix', value: 'remix', optionlabel: 'Remix' },
{ label: 'Astro', value: 'astro', optionlabel: 'Astro' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid' },
];
const StateCombobox = ({
state,
multiple,
}: {
state: 'error' | 'warning' | 'success' | 'ghost';
multiple?: boolean;
}) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: sampleItems,
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
filter: (_itemText: string, filterText: string, item: StoryItem) => contains(item.label, filterText),
});
return (
<Combobox
collection={collection}
filter={filter}
multiple={multiple}
showChip
placeholder={state}
fullWidth
error={state === 'error'}
warning={state === 'warning'}
success={state === 'success'}
ghost={state === 'ghost'}
>
{(collection.items || []).map((item: StoryItem) => (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
</Combobox.Option>
))}
</Combobox>
);
};
const ComboboxStateExample = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', width: '320px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontSize: '14px', fontWeight: 700, color: '#1f2933' }}>Single</div>
<StateCombobox state="error" />
<StateCombobox state="warning" />
<StateCombobox state="success" />
<StateCombobox state="ghost" />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontSize: '14px', fontWeight: 700, color: '#1f2933' }}>Multiple</div>
<StateCombobox state="error" multiple />
<StateCombobox state="warning" multiple />
<StateCombobox state="success" multiple />
<StateCombobox state="ghost" multiple />
</div>
</div>
);
export default ComboboxStateExample;
LongList
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const sampleItems: StoryItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte' },
{ label: 'Next.js', value: 'nextjs', optionlabel: 'Next.js' },
{ label: 'Nuxt.js', value: 'nuxtjs', optionlabel: 'Nuxt.js' },
{ label: 'SvelteKit', value: 'sveltekit', optionlabel: 'SvelteKit' },
{ label: 'Remix', value: 'remix', optionlabel: 'Remix' },
{ label: 'Astro', value: 'astro', optionlabel: 'Astro' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid' },
];
const longListItems: StoryItem[] = [
...sampleItems,
{ label: 'Qwik', value: 'qwik', optionlabel: 'Qwik' },
{ label: 'Lit', value: 'lit', optionlabel: 'Lit' },
{ label: 'Stencil', value: 'stencil', optionlabel: 'Stencil' },
{ label: 'Mithril', value: 'mithril', optionlabel: 'Mithril' },
{ label: 'Preact', value: 'preact', optionlabel: 'Preact' },
{ label: 'Inferno', value: 'inferno', optionlabel: 'Inferno' },
{ label: 'Hyperapp', value: 'hyperapp', optionlabel: 'Hyperapp' },
{ label: 'Alpine.js', value: 'alpinejs', optionlabel: 'Alpine.js' },
{ label: 'Stimulus', value: 'stimulus', optionlabel: 'Stimulus' },
{ label: 'HTMX', value: 'htmx', optionlabel: 'HTMX' },
];
// LongList 스토리를 위한 컴포넌트 (hook 사용을 위해)
const LongListCombobox = (args: Partial<ComboboxProps<StoryItem>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: longListItems,
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
filter: (_itemText: string, filterText: string, item: StoryItem) => contains(item.label, filterText),
});
return (
<Combobox {...args} collection={collection} filter={filter} placeholder="프레임워크를 검색하세요">
<Combobox.ContentScrollWrapper maxHeight="300px">
{(collection.items || []).map((item: StoryItem) => {
return (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
{item.description && (
<Combobox.OptionDescription>{item.description}</Combobox.OptionDescription>
)}
</Combobox.Option>
);
})}
</Combobox.ContentScrollWrapper>
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '프레임워크를 검색하세요',
}) } as ComponentProps<typeof Combobox>;
const ComboboxLongListExampleRender = (args: ComponentProps<typeof Combobox>) => <LongListCombobox {...args} />;
export default function ComboboxLongListExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxLongListExampleRender(mergedProps);
}
LongTextList
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const longTextListItems: StoryItem[] = [
{
label: 'React - A JavaScript library for building user interfaces',
value: 'react',
optionlabel: 'React - A JavaScript library for building user interfaces',
},
{
label: 'Vue.js - The Progressive JavaScript Framework',
value: 'vue',
optionlabel: 'Vue.js - The Progressive JavaScript Framework',
},
{
label: "Angular - The modern web developer's platform",
value: 'angular',
optionlabel: "Angular - The modern web developer's platform",
},
{
label: 'Svelte - Cybernetically enhanced web apps',
value: 'svelte',
optionlabel: 'Svelte - Cybernetically enhanced web apps',
},
{
label: 'Next.js - The React Framework for Production',
value: 'nextjs',
optionlabel: 'Next.js - The React Framework for Production',
},
{
label: 'Nuxt.js - The Intuitive Vue Framework',
value: 'nuxtjs',
optionlabel: 'Nuxt.js - The Intuitive Vue Framework',
},
{
label: 'SvelteKit - The fastest way to build Svelte apps',
value: 'sveltekit',
optionlabel: 'SvelteKit - The fastest way to build Svelte apps',
},
{
label: 'Remix - Build better websites with React',
value: 'remix',
optionlabel: 'Remix - Build better websites with React',
},
{
label: 'Astro - Build faster websites with less client-side JavaScript',
value: 'astro',
optionlabel: 'Astro - Build faster websites with less client-side JavaScript',
},
{
label: 'Solid - A declarative JavaScript library for creating user interfaces',
value: 'solid',
optionlabel: 'Solid - A declarative JavaScript library for creating user interfaces',
},
];
// LongTextList 스토리를 위한 컴포넌트 (hook 사용을 위해)
const LongTextListCombobox = (args: Partial<ComboboxProps<StoryItem>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: longTextListItems,
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
filter: (_itemText: string, filterText: string, item: StoryItem) => contains(item.label, filterText),
});
return (
<Combobox {...args} collection={collection} filter={filter} width="300px">
{(collection.items || []).map((item: StoryItem) => {
return (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
{item.description && <Combobox.OptionDescription>{item.description}</Combobox.OptionDescription>}
</Combobox.Option>
);
})}
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '프레임워크를 검색하세요',
}) } as ComponentProps<typeof Combobox>;
const ComboboxLongTextListExampleRender = (args: ComponentProps<typeof Combobox>) => <LongTextListCombobox {...args} />;
export default function ComboboxLongTextListExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxLongTextListExampleRender(mergedProps);
}
AsyncSearch
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
import React from 'react';
import { useAsyncList } from '@ark-ui/react/collection';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const sampleItems: StoryItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte' },
{ label: 'Next.js', value: 'nextjs', optionlabel: 'Next.js' },
{ label: 'Nuxt.js', value: 'nuxtjs', optionlabel: 'Nuxt.js' },
{ label: 'SvelteKit', value: 'sveltekit', optionlabel: 'SvelteKit' },
{ label: 'Remix', value: 'remix', optionlabel: 'Remix' },
{ label: 'Astro', value: 'astro', optionlabel: 'Astro' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid' },
];
const AsyncCombobox = (args: Partial<ComboboxProps<StoryItem>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const handleLoad = React.useCallback(
async (details: { filterText: string; signal: AbortSignal | undefined }) => {
const { filterText, signal } = details;
const query = filterText.trim();
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
await new Promise<void>((resolve, reject) => {
const timeoutId = window.setTimeout(() => resolve(), 1000);
signal?.addEventListener(
'abort',
() => {
window.clearTimeout(timeoutId);
reject(new DOMException('Aborted', 'AbortError'));
},
{ once: true },
);
});
const filteredItems = query ? sampleItems.filter((item) => contains(item.label, query)) : sampleItems;
return { items: filteredItems };
},
[contains],
);
const asyncList = useAsyncList<StoryItem>({
autoReload: true,
initialFilterText: '',
load: handleLoad,
});
const { collection, set } = useListCollection({
initialItems: [],
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
});
React.useEffect(() => {
set(asyncList.items);
}, [asyncList.items, set]);
const handleFilter = React.useCallback(
(inputValue: string) => {
asyncList.setFilterText(inputValue);
},
[asyncList],
);
return (
<Combobox
{...args}
collection={collection}
filter={handleFilter}
list={{
loading: asyncList.loading,
error: asyncList.error,
filterText: asyncList.filterText,
}}
noOptionsText={asyncList.loading ? '로딩 중...' : args.noOptionsText}
>
{(collection.items || []).map((item: StoryItem) => {
return (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
</Combobox.Option>
);
})}
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '비동기 검색',
}) } as ComponentProps<typeof Combobox>;
const ComboboxAsyncSearchExampleRender = (args: ComponentProps<typeof Combobox>) => <AsyncCombobox {...args} />;
export default function ComboboxAsyncSearchExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxAsyncSearchExampleRender(mergedProps);
}
WithGroupBy
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
interface FrameworkOption extends ComboboxOption {
label: string;
value: string;
description?: string;
category: string;
stars: number;
optionlabel: string;
}
const frameworkOptions: FrameworkOption[] = [
{
label: 'React',
value: 'react',
description: 'Frontend',
category: 'Frontend',
stars: 220000,
optionlabel: 'React',
},
{
label: 'Vue',
value: 'vue',
description: 'Frontend',
category: 'Frontend',
stars: 210000,
optionlabel: 'Vue',
},
{
label: 'Angular',
value: 'angular',
description: 'Frontend',
category: 'Frontend',
stars: 90000,
optionlabel: 'Angular',
},
{
label: 'Next.js',
value: 'nextjs',
description: 'Fullstack',
category: 'Fullstack',
stars: 120000,
optionlabel: 'Next.js',
},
{
label: 'Nuxt.js',
value: 'nuxtjs',
description: 'Fullstack',
category: 'Fullstack',
stars: 50000,
optionlabel: 'Nuxt.js',
},
{
label: 'Express',
value: 'express',
description: 'Backend',
category: 'Backend',
stars: 65000,
optionlabel: 'Express',
},
{
label: 'NestJS',
value: 'nestjs',
description: 'Backend',
category: 'Backend',
stars: 68000,
optionlabel: 'NestJS',
},
{
label: 'Svelte',
value: 'svelte',
description: 'Frontend',
category: 'Frontend',
stars: 75000,
optionlabel: 'Svelte',
},
{
label: 'Remix',
value: 'remix',
description: 'Fullstack',
category: 'Fullstack',
stars: 28000,
optionlabel: 'Remix',
},
];
// ============================================================================
// GroupBy 스토리
// ============================================================================
// GroupBy를 사용하는 스토리
const GroupByCombobox = (args: Partial<ComboboxProps<FrameworkOption>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: frameworkOptions,
itemToString: (item: FrameworkOption) => item.label,
itemToValue: (item: FrameworkOption) => item.value,
filter: (_itemText: string, filterText: string, item: FrameworkOption) =>
contains(item.label, filterText),
groupBy: (item: FrameworkOption) => item.category,
});
// 카테고리별로 그룹화 (필터링된 collection.items 사용)
const groupedByCategory = (collection.items || []).reduce(
(acc: Record<string, FrameworkOption[]>, item: FrameworkOption) => {
const category = item.category;
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(item);
return acc;
},
{} as Record<string, FrameworkOption[]>,
);
return (
<Combobox collection={collection} filter={filter} {...args}>
{Object.entries(groupedByCategory).map(([category, items], index, entries) => (
<Combobox.ItemGroup key={category}>
<Combobox.ItemGroupLabel>{category}</Combobox.ItemGroupLabel>
{items.map((item: FrameworkOption) => (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
{item.description && (
<Combobox.OptionDescription>{item.description}</Combobox.OptionDescription>
)}
</Combobox.Option>
))}
{index < entries.length - 1 && <Combobox.Separator />}
</Combobox.ItemGroup>
))}
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '카테고리별로 그룹화된 프레임워크',
}) } as ComponentProps<typeof Combobox>;
const ComboboxWithGroupByExampleRender = (args: ComponentProps<typeof Combobox>) => <GroupByCombobox {...args} />;
export default function ComboboxWithGroupByExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxWithGroupByExampleRender(mergedProps);
}
UserSearch
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 사용자 검색 스토리
// ============================================================================
// 예시 6: 사용자 검색 (이름, 이메일로 필터링)
interface UserOption extends ComboboxOption {
id: string;
name: string;
email: string;
avatarColor?: string;
optionlabel: string;
}
const userItems: UserOption[] = [
{
id: 'user-1',
name: '김밀당',
email: 'mildang2@ihateflyingbugs.com',
avatarColor: 'pink',
optionlabel: '김밀당',
},
{
id: 'user-2',
name: '김파리',
email: 'fly.kim@Iamflyingbugs.ha',
avatarColor: 'pink',
optionlabel: '김파리',
},
{
id: 'user-3',
name: '김스키토',
email: 'kimsquitto@ihateflyingbugs.com',
avatarColor: 'green',
optionlabel: '김스키토',
},
{
id: 'user-4',
name: '이벌레',
email: 'bug.lee@ihateflyingbugs.com',
avatarColor: 'blue',
optionlabel: '이벌레',
},
{
id: 'user-5',
name: '박모기',
email: 'mosquito.park@ihateflyingbugs.com',
avatarColor: 'orange',
optionlabel: '박모기',
},
{
id: 'user-6',
name: '최나방',
email: 'beetle.choi@ihateflyingbugs.com',
avatarColor: 'purple',
optionlabel: '최나방',
},
];
const UserCombobox = (args: Partial<ComboboxProps<UserOption>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: userItems,
itemToString: (item: UserOption) => item.name,
itemToValue: (item: UserOption) => item.id,
filter: (_itemText: string, filterText: string, item: UserOption) =>
contains(item.name, filterText) || contains(item.email, filterText),
});
return (
<Combobox
{...args}
collection={collection}
filter={filter}
placeholder="사용자 이름 또는 이메일로 검색"
width="330px"
>
{(collection.items || []).map((item: UserOption) => {
return (
<Combobox.Option key={item.id} item={item.id} descriptionPosition="bottom">
<Combobox.OptionLabel>{item.name}</Combobox.OptionLabel>
<Combobox.OptionDescription>{item.email}</Combobox.OptionDescription>
</Combobox.Option>
);
})}
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '사용자 이름 또는 이메일로 검색',
}) } as ComponentProps<typeof Combobox>;
const ComboboxUserSearchExampleRender = (args: ComponentProps<typeof Combobox>) => <UserCombobox {...args} />;
export default function ComboboxUserSearchExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxUserSearchExampleRender(mergedProps);
}
WithoutSearchIcon
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const sampleItems: StoryItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte' },
{ label: 'Next.js', value: 'nextjs', optionlabel: 'Next.js' },
{ label: 'Nuxt.js', value: 'nuxtjs', optionlabel: 'Nuxt.js' },
{ label: 'SvelteKit', value: 'sveltekit', optionlabel: 'SvelteKit' },
{ label: 'Remix', value: 'remix', optionlabel: 'Remix' },
{ label: 'Astro', value: 'astro', optionlabel: 'Astro' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid' },
];
// ============================================================================
// 검색 아이콘 없는 Combobox 스토리
// ============================================================================
const WithoutSearchIconCombobox = (args: Partial<ComboboxProps<StoryItem>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: sampleItems,
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
filter: (_itemText: string, filterText: string, item: StoryItem) => contains(item.label, filterText),
});
return (
<Combobox
{...args}
collection={collection}
filter={filter}
placeholder="검색 아이콘 없이 표시됩니다"
InputProps={{ startAdornment: null }}
>
{(collection.items || []).map((item: StoryItem) => {
return (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
{item.description && <Combobox.OptionDescription>{item.description}</Combobox.OptionDescription>}
</Combobox.Option>
);
})}
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '검색 아이콘 없이 표시됩니다',
}) } as ComponentProps<typeof Combobox>;
const ComboboxWithoutSearchIconExampleRender = (args: ComponentProps<typeof Combobox>) => <WithoutSearchIconCombobox {...args} />;
export default function ComboboxWithoutSearchIconExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxWithoutSearchIconExampleRender(mergedProps);
}
HideClearButton
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const sampleItems: StoryItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte' },
{ label: 'Next.js', value: 'nextjs', optionlabel: 'Next.js' },
{ label: 'Nuxt.js', value: 'nuxtjs', optionlabel: 'Nuxt.js' },
{ label: 'SvelteKit', value: 'sveltekit', optionlabel: 'SvelteKit' },
{ label: 'Remix', value: 'remix', optionlabel: 'Remix' },
{ label: 'Astro', value: 'astro', optionlabel: 'Astro' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid' },
];
const HideClearButtonCombobox = (args: Partial<ComboboxProps<StoryItem>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: sampleItems,
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
filter: (_itemText: string, filterText: string, item: StoryItem) => contains(item.label, filterText),
});
return (
<Combobox {...args} collection={collection} filter={filter} hideClearButton>
{(collection.items || []).map((item: StoryItem) => {
return (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
{item.description && <Combobox.OptionDescription>{item.description}</Combobox.OptionDescription>}
</Combobox.Option>
);
})}
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '값을 선택해도 X 버튼이 표시되지 않습니다',
}) } as ComponentProps<typeof Combobox>;
const ComboboxHideClearButtonExampleRender = (args: ComponentProps<typeof Combobox>) => <HideClearButtonCombobox {...args} />;
export default function ComboboxHideClearButtonExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxHideClearButtonExampleRender(mergedProps);
}
NumberInput
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
import React from 'react';
// ============================================================================
// Number Input 스토리
// ============================================================================
const TEXT_SIZES = ['40', '32', '28', '24', '20', '18', '16'];
const NumberInputCombobox = (args: Partial<ComboboxProps<string>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: TEXT_SIZES,
itemToString: (item: string) => item,
itemToValue: (item: string) => item,
filter: (_itemText: string, filterText: string, item: string) => contains(item, filterText),
});
const [inputValue, setInputValue] = React.useState('18');
const handleInputChange = (details: { inputValue: string }) => {
setInputValue(details.inputValue);
};
return (
<Combobox
{...args}
collection={collection}
filter={filter}
defaultInputValue={inputValue}
onInputChange={handleInputChange}
allowCustomValue
closeOnSelect={false}
width="86px"
InputProps={{
type: 'number',
startAdornment: null,
endAdornment: null,
}}
>
{(collection.items || []).map((item: string) => {
return (
<Combobox.Option key={item} item={item}>
<Combobox.OptionLabel>{item}</Combobox.OptionLabel>
</Combobox.Option>
);
})}
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '',
}) } as ComponentProps<typeof Combobox>;
const ComboboxNumberInputExampleRender = (args: ComponentProps<typeof Combobox>) => <NumberInputCombobox {...args} />;
export default function ComboboxNumberInputExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxNumberInputExampleRender(mergedProps);
}
SearchIconEnd
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const sampleItems: StoryItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte' },
{ label: 'Next.js', value: 'nextjs', optionlabel: 'Next.js' },
{ label: 'Nuxt.js', value: 'nuxtjs', optionlabel: 'Nuxt.js' },
{ label: 'SvelteKit', value: 'sveltekit', optionlabel: 'SvelteKit' },
{ label: 'Remix', value: 'remix', optionlabel: 'Remix' },
{ label: 'Astro', value: 'astro', optionlabel: 'Astro' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid' },
];
// ============================================================================
// SearchIconEnd 스토리
// ============================================================================
const SearchIconEndCombobox = (args: Partial<ComboboxProps<StoryItem>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: sampleItems,
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
filter: (_itemText: string, filterText: string, item: StoryItem) => contains(item.label, filterText),
});
return (
<Combobox
{...args}
collection={collection}
filter={filter}
searchIconPosition="end"
placeholder="검색어를 입력하세요"
>
{(collection.items || []).map((item: StoryItem) => (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
</Combobox.Option>
))}
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '검색어를 입력하세요',
}) } as ComponentProps<typeof Combobox>;
const ComboboxSearchIconEndExampleRender = (args: ComponentProps<typeof Combobox>) => <SearchIconEndCombobox {...args} />;
export default function ComboboxSearchIconEndExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxSearchIconEndExampleRender(mergedProps);
}
[코드] Highlight
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const sampleItems: StoryItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte' },
{ label: 'Next.js', value: 'nextjs', optionlabel: 'Next.js' },
{ label: 'Nuxt.js', value: 'nuxtjs', optionlabel: 'Nuxt.js' },
{ label: 'SvelteKit', value: 'sveltekit', optionlabel: 'SvelteKit' },
{ label: 'Remix', value: 'remix', optionlabel: 'Remix' },
{ label: 'Astro', value: 'astro', optionlabel: 'Astro' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid' },
];
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '하이라이트 테스트',
}) } as ComponentProps<typeof Combobox>;
const ComboboxHighlightExampleRender = (args: ComponentProps<typeof Combobox>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: sampleItems,
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
filter: (_itemText: string, filterText: string, item: StoryItem) => contains(item.label, filterText),
});
return (
<Combobox {...args} collection={collection} filter={filter}>
{(collection.items || []).map((item: StoryItem) => {
return (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel highlight>{item.label}</Combobox.OptionLabel>
{item.description && (
<Combobox.OptionDescription>{item.description}</Combobox.OptionDescription>
)}
</Combobox.Option>
);
})}
</Combobox>
);
};
export default function ComboboxHighlightExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxHighlightExampleRender(mergedProps);
}
ChipOptionsSingle
코드
import type { ComponentProps } from 'react';
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
import { renderChipTag } from '@mildang/design-system/Combobox';
import { Chip } from '@mildang/design-system/Chip';
import { type ChipVariantProps } from '@mildang/styled-system/recipes';
// ============================================================================
// Chip 옵션 스토리
// ============================================================================
type ChipItem = {
label: string;
value: string;
color: NonNullable<ChipVariantProps['color']>;
};
const chipStatusItems: ChipItem[] = [
{ label: '완료', value: 'done', color: 'green' },
{ label: '진행 중', value: 'in_progress', color: 'blue' },
{ label: '대기', value: 'pending', color: 'orange' },
{ label: '오류', value: 'error', color: 'red' },
];
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '상태를 선택하세요',
}) } as ComponentProps<typeof Combobox>;
const ComboboxChipOptionsSingleExampleRender = (args: ComponentProps<typeof Combobox>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: chipStatusItems,
itemToString: (item: ChipItem) => item.label,
itemToValue: (item: ChipItem) => item.value,
filter: (_itemText: string, filterText: string, item: ChipItem) => contains(item.label, filterText),
});
return (
// Combobox 단일 모드는 트리거가 <Input> 이라 chip 을 렌더할 자리가 없다 (텍스트만).
// 트리거에 chip 하나를 노출하려면 `singleChip` prop 을 켠다 — 내부적으로
// multiple + max=1 + closeOnSelect 로 위임되어 TagsInput 이 chip 1 개만 허용.
<Combobox {...args} collection={collection} filter={filter} singleChip renderTag={renderChipTag}>
{(collection.items || []).map((item: ChipItem) => (
<Combobox.Option key={item.value} item={item.value}>
<Chip label={item.label} color={item.color} type="text" size="sm" />
</Combobox.Option>
))}
</Combobox>
);
};
export default function ComboboxChipOptionsSingleExample(props: Partial<ComponentProps<typeof Combobox>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Combobox>;
return ComboboxChipOptionsSingleExampleRender(mergedProps);
}
AddOption
코드
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
import { useCallback, useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
import { Combobox as ArkCombobox } from '@ark-ui/react/combobox';
export interface UseCreateOptionComboboxOptions<T> {
/**
* 초기 아이템 목록
*/
initialItems: T[];
/**
* 아이템을 문자열로 변환하는 함수
*/
itemToString: (item: T) => string;
/**
* 아이템을 값으로 변환하는 함수
*/
itemToValue: (item: T) => string;
/**
* 필터 함수 (선택사항)
* 제공하지 않으면 기본 필터링 로직 사용
*/
filter?: (itemText: string, filterText: string, item: T) => boolean;
/**
* 새 아이템 생성 함수 (임시 옵션용, 선택사항)
* 제공하지 않으면 기본 생성 로직 사용
*/
createNewOption?: (value: string, newOptionValue: string) => T;
/**
* 새 아이템 데이터 생성 함수 (실제 추가용, 선택사항)
* 제공하지 않으면 기본 생성 로직 사용
*/
getNewOptionData?: (value: string) => T;
}
export interface UseCreateOptionComboboxReturn<T> {
/**
* Collection 객체
*/
collection: ReturnType<typeof useListCollection<T>>['collection'];
/**
* 필터 함수
*/
filter: ReturnType<typeof useListCollection<T>>['filter'];
/**
* 선택된 값들
*/
selectedValues: string[];
/**
* 현재 입력값
*/
inputValue: string;
/**
* Input 변경 핸들러
*/
handleInputChange: (details: ArkCombobox.InputValueChangeDetails) => void;
/**
* 선택 변경 핸들러
*/
handleValueChange: (details: { value: (string | number)[] }) => void;
/**
* 새 옵션 값인지 확인하는 함수
*/
isNewOptionValue: (value: string) => boolean;
/**
* 새 옵션 값 상수
*/
NEW_OPTION_VALUE: string;
/**
* 옵션 삭제 핸들러
*/
handleDeleteOption: (item: T) => void;
/**
* 아이템 삭제 가능 여부 판단 함수
*/
canDeleteItem: (item: T) => boolean;
}
/**
* Combobox에서 "추가하기" 옵션 기능을 사용하기 위한 커스텀 훅
* 입력값에 따라 "{keyword}" 추가하기 옵션을 동적으로 표시하고 추가할 수 있게 해줍니다.
*
* @example
* ```tsx
* const {
* collection,
* filter,
* selectedValues,
* inputValue,
* handleInputChange,
* handleValueChange,
* isNewOptionValue,
* NEW_OPTION_VALUE,
* } = useCreateOptionCombobox({
* initialItems: items,
* itemToString: (item) => item.label,
* itemToValue: (item) => item.value,
* createNewOption: (value) => ({ label: value, value: NEW_OPTION_VALUE }),
* getNewOptionData: (value) => ({ label: value, value, __new__: true }),
* });
* ```
*/
export function useCreateOptionCombobox<T extends { value: string }>(
options: UseCreateOptionComboboxOptions<T>,
): UseCreateOptionComboboxReturn<T> {
const {
initialItems,
itemToString,
itemToValue,
filter: customFilter,
createNewOption: customCreateNewOption,
getNewOptionData: customGetNewOptionData,
} = options;
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter, upsert, remove, update } = useListCollection({
initialItems,
itemToString,
itemToValue,
filter:
customFilter ||
((_itemText: string, filterText: string, item: T) => {
// 검색어가 없으면 전체 목록 표시
if (!filterText || filterText.trim() === '') {
return true;
}
// 검색어가 있으면 필터링 적용
return contains(itemToString(item), filterText);
}),
});
// 초기 아이템들의 value를 Set으로 저장 (기본 옵션 추적 => 추가된 아이템만 삭제가능)
const initialItemValues = useMemo(
() => new Set(initialItems.map((item) => itemToValue(item))),
[initialItems, itemToValue],
);
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
console.log(inputValue, 'inputValue');
const NEW_OPTION_VALUE = '[[new]]';
const isNewOptionValue = useCallback((value: string) => value === NEW_OPTION_VALUE, []);
const replaceNewOptionValue = useCallback(
(values: string[], value: string) => values.map((v) => (v === NEW_OPTION_VALUE ? value : v)),
[],
);
// 기본 새 옵션 생성 함수 (임시 옵션용)
const createNewOption = useCallback(
(value: string, newOptionValue: string): T => {
if (customCreateNewOption) {
return customCreateNewOption(value, newOptionValue);
}
// 기본 구현: value를 label로 사용하고, newOptionValue를 value로 설정
// optionlabel도 label과 동일하게 설정 (일반적인 패턴)
const label = value;
return { value: newOptionValue, label, optionlabel: label } as unknown as T;
},
[customCreateNewOption],
);
// 기본 새 옵션 데이터 생성 함수 (실제 추가용)
const getNewOptionData = useCallback(
(value: string): T => {
if (customGetNewOptionData) {
return customGetNewOptionData(value);
}
// 기본 구현: value를 그대로 사용하고, label과 optionlabel도 value로 설정
// __new__ 속성도 추가하여 새로 추가된 아이템임을 표시
return { value, label: value, optionlabel: value, __new__: true } as unknown as T;
},
[customGetNewOptionData],
);
const isValidNewOption = useCallback(
(inputValue: string) => {
if (!inputValue.trim()) {
return false;
}
const exactOptionMatch = (collection.items || []).some(
(item: T) => itemToString(item).toLowerCase() === inputValue.toLowerCase(),
);
return !exactOptionMatch && inputValue.trim().length > 0;
},
[collection, itemToString],
);
const handleInputChange = useCallback(
(details: ArkCombobox.InputValueChangeDetails) => {
const { inputValue: newInputValue, reason } = details;
if (reason === 'input-change' || reason === 'item-select') {
flushSync(() => {
if (isValidNewOption(newInputValue)) {
upsert(NEW_OPTION_VALUE, createNewOption(newInputValue, NEW_OPTION_VALUE));
} else if (newInputValue.trim().length === 0) {
remove(NEW_OPTION_VALUE);
}
});
}
setInputValue(newInputValue);
},
[isValidNewOption, upsert, remove, createNewOption],
);
const handleValueChange = useCallback(
(details: { value: (string | number)[] }) => {
const newSelectedValues = replaceNewOptionValue(
(details.value || []).map((v) => String(v)),
inputValue,
);
setSelectedValues(newSelectedValues);
if (details.value?.includes(NEW_OPTION_VALUE)) {
update(NEW_OPTION_VALUE, getNewOptionData(inputValue));
}
},
[inputValue, replaceNewOptionValue, update, getNewOptionData],
);
// 삭제
const handleDeleteOption = useCallback(
(item: T) => {
remove(itemToValue(item));
// 삭제 시 선택에서도 제거
setSelectedValues((prev) => prev.filter((v) => v !== itemToValue(item)));
},
[remove, itemToValue],
);
// 새로 추가된 커스텀 옵션만 삭제 가능하도록 판단
const canDeleteItem = useCallback(
(item: T) => {
// 초기 아이템이 아니면 삭제 가능 (새로 추가된 커스텀 옵션)
return !initialItemValues.has(itemToValue(item));
},
[initialItemValues, itemToValue],
);
return {
collection,
filter,
selectedValues,
inputValue,
handleInputChange,
handleValueChange,
isNewOptionValue,
NEW_OPTION_VALUE,
handleDeleteOption,
canDeleteItem,
};
}
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const sampleItems: StoryItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte' },
{ label: 'Next.js', value: 'nextjs', optionlabel: 'Next.js' },
{ label: 'Nuxt.js', value: 'nuxtjs', optionlabel: 'Nuxt.js' },
{ label: 'SvelteKit', value: 'sveltekit', optionlabel: 'SvelteKit' },
{ label: 'Remix', value: 'remix', optionlabel: 'Remix' },
{ label: 'Astro', value: 'astro', optionlabel: 'Astro' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid' },
];
// ============================================================================
// AddOption 스토리
// ============================================================================
const AddOptionCombobox = (args: Partial<ComboboxProps<StoryItem>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const {
collection,
filter,
selectedValues,
handleInputChange,
handleValueChange,
isNewOptionValue,
handleDeleteOption,
canDeleteItem,
} = useCreateOptionCombobox({
initialItems: sampleItems,
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
filter: (_itemText: string, filterText: string, item: StoryItem) => contains(item.label, filterText),
});
return (
<Combobox
{...args}
collection={collection}
filter={filter}
value={selectedValues}
onInputChange={handleInputChange}
onValueChange={handleValueChange}
onDeleteOption={handleDeleteOption}
canDeleteItem={canDeleteItem}
>
{(collection.items || []).map((item: StoryItem) => {
return (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>
{isNewOptionValue(item.value) ? `"${item.label}" 추가하기` : item.label}
</Combobox.OptionLabel>
{item.description && <Combobox.OptionDescription>{item.description}</Combobox.OptionDescription>}
</Combobox.Option>
);
})}
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '프레임워크를 검색하거나 추가하세요',
multiple: true,
}) } as ComboboxProps<StoryItem>;
const ComboboxAddOptionExampleRender = (args: ComboboxProps<StoryItem>) => <AddOptionCombobox {...args} />;
export default function ComboboxAddOptionExample(props: Partial<ComboboxProps<StoryItem>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComboboxProps<StoryItem>;
return ComboboxAddOptionExampleRender(mergedProps);
}
AddOptionSingle
코드
import { Combobox as Combobox } from '@mildang/design-system/Combobox';
import { type ComboboxProps } from '@mildang/design-system/Combobox';
import { useFilter, useListCollection } from '@ark-ui/react';
import { useCallback, useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
import { Combobox as ArkCombobox } from '@ark-ui/react/combobox';
export interface UseCreateOptionComboboxOptions<T> {
/**
* 초기 아이템 목록
*/
initialItems: T[];
/**
* 아이템을 문자열로 변환하는 함수
*/
itemToString: (item: T) => string;
/**
* 아이템을 값으로 변환하는 함수
*/
itemToValue: (item: T) => string;
/**
* 필터 함수 (선택사항)
* 제공하지 않으면 기본 필터링 로직 사용
*/
filter?: (itemText: string, filterText: string, item: T) => boolean;
/**
* 새 아이템 생성 함수 (임시 옵션용, 선택사항)
* 제공하지 않으면 기본 생성 로직 사용
*/
createNewOption?: (value: string, newOptionValue: string) => T;
/**
* 새 아이템 데이터 생성 함수 (실제 추가용, 선택사항)
* 제공하지 않으면 기본 생성 로직 사용
*/
getNewOptionData?: (value: string) => T;
}
export interface UseCreateOptionComboboxReturn<T> {
/**
* Collection 객체
*/
collection: ReturnType<typeof useListCollection<T>>['collection'];
/**
* 필터 함수
*/
filter: ReturnType<typeof useListCollection<T>>['filter'];
/**
* 선택된 값들
*/
selectedValues: string[];
/**
* 현재 입력값
*/
inputValue: string;
/**
* Input 변경 핸들러
*/
handleInputChange: (details: ArkCombobox.InputValueChangeDetails) => void;
/**
* 선택 변경 핸들러
*/
handleValueChange: (details: { value: (string | number)[] }) => void;
/**
* 새 옵션 값인지 확인하는 함수
*/
isNewOptionValue: (value: string) => boolean;
/**
* 새 옵션 값 상수
*/
NEW_OPTION_VALUE: string;
/**
* 옵션 삭제 핸들러
*/
handleDeleteOption: (item: T) => void;
/**
* 아이템 삭제 가능 여부 판단 함수
*/
canDeleteItem: (item: T) => boolean;
}
/**
* Combobox에서 "추가하기" 옵션 기능을 사용하기 위한 커스텀 훅
* 입력값에 따라 "{keyword}" 추가하기 옵션을 동적으로 표시하고 추가할 수 있게 해줍니다.
*
* @example
* ```tsx
* const {
* collection,
* filter,
* selectedValues,
* inputValue,
* handleInputChange,
* handleValueChange,
* isNewOptionValue,
* NEW_OPTION_VALUE,
* } = useCreateOptionCombobox({
* initialItems: items,
* itemToString: (item) => item.label,
* itemToValue: (item) => item.value,
* createNewOption: (value) => ({ label: value, value: NEW_OPTION_VALUE }),
* getNewOptionData: (value) => ({ label: value, value, __new__: true }),
* });
* ```
*/
export function useCreateOptionCombobox<T extends { value: string }>(
options: UseCreateOptionComboboxOptions<T>,
): UseCreateOptionComboboxReturn<T> {
const {
initialItems,
itemToString,
itemToValue,
filter: customFilter,
createNewOption: customCreateNewOption,
getNewOptionData: customGetNewOptionData,
} = options;
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter, upsert, remove, update } = useListCollection({
initialItems,
itemToString,
itemToValue,
filter:
customFilter ||
((_itemText: string, filterText: string, item: T) => {
// 검색어가 없으면 전체 목록 표시
if (!filterText || filterText.trim() === '') {
return true;
}
// 검색어가 있으면 필터링 적용
return contains(itemToString(item), filterText);
}),
});
// 초기 아이템들의 value를 Set으로 저장 (기본 옵션 추적 => 추가된 아이템만 삭제가능)
const initialItemValues = useMemo(
() => new Set(initialItems.map((item) => itemToValue(item))),
[initialItems, itemToValue],
);
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
console.log(inputValue, 'inputValue');
const NEW_OPTION_VALUE = '[[new]]';
const isNewOptionValue = useCallback((value: string) => value === NEW_OPTION_VALUE, []);
const replaceNewOptionValue = useCallback(
(values: string[], value: string) => values.map((v) => (v === NEW_OPTION_VALUE ? value : v)),
[],
);
// 기본 새 옵션 생성 함수 (임시 옵션용)
const createNewOption = useCallback(
(value: string, newOptionValue: string): T => {
if (customCreateNewOption) {
return customCreateNewOption(value, newOptionValue);
}
// 기본 구현: value를 label로 사용하고, newOptionValue를 value로 설정
// optionlabel도 label과 동일하게 설정 (일반적인 패턴)
const label = value;
return { value: newOptionValue, label, optionlabel: label } as unknown as T;
},
[customCreateNewOption],
);
// 기본 새 옵션 데이터 생성 함수 (실제 추가용)
const getNewOptionData = useCallback(
(value: string): T => {
if (customGetNewOptionData) {
return customGetNewOptionData(value);
}
// 기본 구현: value를 그대로 사용하고, label과 optionlabel도 value로 설정
// __new__ 속성도 추가하여 새로 추가된 아이템임을 표시
return { value, label: value, optionlabel: value, __new__: true } as unknown as T;
},
[customGetNewOptionData],
);
const isValidNewOption = useCallback(
(inputValue: string) => {
if (!inputValue.trim()) {
return false;
}
const exactOptionMatch = (collection.items || []).some(
(item: T) => itemToString(item).toLowerCase() === inputValue.toLowerCase(),
);
return !exactOptionMatch && inputValue.trim().length > 0;
},
[collection, itemToString],
);
const handleInputChange = useCallback(
(details: ArkCombobox.InputValueChangeDetails) => {
const { inputValue: newInputValue, reason } = details;
if (reason === 'input-change' || reason === 'item-select') {
flushSync(() => {
if (isValidNewOption(newInputValue)) {
upsert(NEW_OPTION_VALUE, createNewOption(newInputValue, NEW_OPTION_VALUE));
} else if (newInputValue.trim().length === 0) {
remove(NEW_OPTION_VALUE);
}
});
}
setInputValue(newInputValue);
},
[isValidNewOption, upsert, remove, createNewOption],
);
const handleValueChange = useCallback(
(details: { value: (string | number)[] }) => {
const newSelectedValues = replaceNewOptionValue(
(details.value || []).map((v) => String(v)),
inputValue,
);
setSelectedValues(newSelectedValues);
if (details.value?.includes(NEW_OPTION_VALUE)) {
update(NEW_OPTION_VALUE, getNewOptionData(inputValue));
}
},
[inputValue, replaceNewOptionValue, update, getNewOptionData],
);
// 삭제
const handleDeleteOption = useCallback(
(item: T) => {
remove(itemToValue(item));
// 삭제 시 선택에서도 제거
setSelectedValues((prev) => prev.filter((v) => v !== itemToValue(item)));
},
[remove, itemToValue],
);
// 새로 추가된 커스텀 옵션만 삭제 가능하도록 판단
const canDeleteItem = useCallback(
(item: T) => {
// 초기 아이템이 아니면 삭제 가능 (새로 추가된 커스텀 옵션)
return !initialItemValues.has(itemToValue(item));
},
[initialItemValues, itemToValue],
);
return {
collection,
filter,
selectedValues,
inputValue,
handleInputChange,
handleValueChange,
isNewOptionValue,
NEW_OPTION_VALUE,
handleDeleteOption,
canDeleteItem,
};
}
// ComboboxOption은 더 이상 사용하지 않으므로 로컬 타입으로 정의
interface ComboboxOption {
optionlabel: string;
}
// ============================================================================
// 데이터 정의
// ============================================================================
interface StoryItem extends ComboboxOption {
label: string;
value: string;
description?: string;
optionlabel: string;
__new__?: boolean;
}
const sampleItems: StoryItem[] = [
{ label: 'React', value: 'react', optionlabel: 'React' },
{ label: 'Vue', value: 'vue', optionlabel: 'Vue' },
{ label: 'Angular', value: 'angular', optionlabel: 'Angular' },
{ label: 'Svelte', value: 'svelte', optionlabel: 'Svelte' },
{ label: 'Next.js', value: 'nextjs', optionlabel: 'Next.js' },
{ label: 'Nuxt.js', value: 'nuxtjs', optionlabel: 'Nuxt.js' },
{ label: 'SvelteKit', value: 'sveltekit', optionlabel: 'SvelteKit' },
{ label: 'Remix', value: 'remix', optionlabel: 'Remix' },
{ label: 'Astro', value: 'astro', optionlabel: 'Astro' },
{ label: 'Solid', value: 'solid', optionlabel: 'Solid' },
];
// ============================================================================
// AddOption 스토리
// ============================================================================
const AddOptionCombobox = (args: Partial<ComboboxProps<StoryItem>>) => {
const { contains } = useFilter({ sensitivity: 'base' });
const {
collection,
filter,
selectedValues,
handleInputChange,
handleValueChange,
isNewOptionValue,
handleDeleteOption,
canDeleteItem,
} = useCreateOptionCombobox({
initialItems: sampleItems,
itemToString: (item: StoryItem) => item.label,
itemToValue: (item: StoryItem) => item.value,
filter: (_itemText: string, filterText: string, item: StoryItem) => contains(item.label, filterText),
});
return (
<Combobox
{...args}
collection={collection}
filter={filter}
value={selectedValues}
onInputChange={handleInputChange}
onValueChange={handleValueChange}
onDeleteOption={handleDeleteOption}
canDeleteItem={canDeleteItem}
>
{(collection.items || []).map((item: StoryItem) => {
return (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>
{isNewOptionValue(item.value) ? `"${item.label}" 추가하기` : item.label}
</Combobox.OptionLabel>
{item.description && <Combobox.OptionDescription>{item.description}</Combobox.OptionDescription>}
</Combobox.Option>
);
})}
</Combobox>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
placeholder: '프레임워크를 검색하거나 추가하세요',
}) } as ComboboxProps<StoryItem>;
const ComboboxAddOptionSingleExampleRender = (args: ComboboxProps<StoryItem>) => <AddOptionCombobox {...args} />;
export default function ComboboxAddOptionSingleExample(props: Partial<ComboboxProps<StoryItem>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComboboxProps<StoryItem>;
return ComboboxAddOptionSingleExampleRender(mergedProps);
}