ChipInput
칩(태그) 형태로 여러 항목을 입력하고 관리하는 컴포넌트
목차
텍스트를 입력하고 Enter로 확인하면 칩(태그) 형태로 추가되는 다중 항목 입력 컴포넌트입니다. 각 칩에는 제거 버튼이 있고, 칩 목록은 List<String>이라 Flutter / Web API가 동일합니다.
기본 사용법 (Basic Usage)#
// 기본 칩 입력
ChipInput(
chips: tags,
placeholder: 'Add a tag...',
onChanged: (chips) => setState(() => tags = chips),
onSubmitted: (text) => setState(() => tags = [...tags, text]),
)
// 최대 개수 제한
ChipInput(
chips: tags,
maxChips: 5,
onChanged: handleChanged,
onSubmitted: handleSubmitted,
)
// 기본 칩 입력
ChipInput(
chips: tags,
placeholder: 'Add a tag...',
onChanged: (chips) => setState(() => tags = chips),
onSubmitted: (text) => setState(() => tags = [...tags, text]),
)
// 최대 개수 제한
ChipInput(
chips: tags,
maxChips: 5,
onChanged: handleChanged,
onSubmitted: handleSubmitted,
)
빠른 오버라이드 (Chain)#
이미 만든 ChipInput 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius16처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius16 ==
CoreRadius.radius16) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class ChipInputChainExample extends StatefulWidget {
const ChipInputChainExample({super.key});
@override
State<ChipInputChainExample> createState() => _ChipInputChainExampleState();
}
class _ChipInputChainExampleState extends State<ChipInputChainExample> {
List<String> _chips = const <String>['Flutter', 'Dart'];
void handleChanged(List<String> chips) {
setState(() {
_chips = chips;
});
}
void handleSubmitted(String text) {
setState(() {
_chips = [..._chips, text];
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
ChipInput(
chips: _chips,
placeholder: 'Add a tag...',
onChanged: handleChanged,
onSubmitted: handleSubmitted,
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
ChipInput(
chips: _chips,
placeholder: 'Add a tag...',
onChanged: handleChanged,
onSubmitted: handleSubmitted,
).withStyle(
const CoreChipInputStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
chipSpacing: CoreSpace.space8,
),
),
],
);
}
}
class ChipInputChainExample extends StatefulComponent {
const ChipInputChainExample({super.key});
@override
State<ChipInputChainExample> createState() => _ChipInputChainExampleState();
}
class _ChipInputChainExampleState extends State<ChipInputChainExample> {
List<String> _chips = const <String>['Flutter', 'Dart'];
void handleChanged(List<String> chips) {
setState(() {
_chips = chips;
});
}
void handleSubmitted(String text) {
setState(() {
_chips = [..._chips, text];
});
}
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
ChipInput(
chips: _chips,
placeholder: 'Add a tag...',
onChanged: handleChanged,
onSubmitted: handleSubmitted,
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
ChipInput(
chips: _chips,
placeholder: 'Add a tag...',
onChanged: handleChanged,
onSubmitted: handleSubmitted,
).withStyle(
const CoreChipInputStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
chipSpacing: CoreSpace.space8,
),
),
],
classes: 'flex flex-col items-start',
);
}
}
Live Preview#
코드 보기
class ChipInputDefaultExample extends StatefulComponent {
const ChipInputDefaultExample({super.key});
@override
State<ChipInputDefaultExample> createState() =>
_ChipInputDefaultExampleState();
}
class _ChipInputDefaultExampleState extends State<ChipInputDefaultExample> {
List<String> _chips = const <String>['Flutter', 'Dart'];
void handleChanged(List<String> chips) {
setState(() {
_chips = chips;
});
}
void handleSubmitted(String text) {
setState(() {
_chips = [..._chips, text];
});
}
@override
Component build(BuildContext context) {
return ChipInput(
chips: _chips,
placeholder: 'Add a tag...',
onChanged: handleChanged,
onSubmitted: handleSubmitted,
);
}
}
class ChipInputDefaultExample extends StatefulWidget {
const ChipInputDefaultExample({super.key});
@override
State<ChipInputDefaultExample> createState() =>
_ChipInputDefaultExampleState();
}
class _ChipInputDefaultExampleState extends State<ChipInputDefaultExample> {
List<String> _chips = const <String>['Flutter', 'Dart'];
void handleChanged(List<String> chips) {
setState(() {
_chips = chips;
});
}
void handleSubmitted(String text) {
setState(() {
_chips = [..._chips, text];
});
}
@override
Widget build(BuildContext context) {
return ChipInput(
chips: _chips,
placeholder: 'Add a tag...',
onChanged: handleChanged,
onSubmitted: handleSubmitted,
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 태그 / 키워드처럼 자유 입력 다중 값을 받는 경우
- 입력한 항목을 칩으로 시각화하고 개별 제거가 필요한 경우
대신 다른 컴포넌트를 사용하세요:
- ChipGroup: 자유 문자열을 새로 만드는 대신 정해진 항목 집합을 단일·다중 선택할 때
- Autocomplete: 문자열 목록 대신 입력 문자열 하나에 추천을 적용할 때
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
chips |
List<String> |
[] |
현재 칩 목록 |
placeholder |
String? |
null |
입력 필드 안내 문구 |
enabled | bool | true | 입력 가능 여부 |
maxChips |
int? |
null |
최대 칩 개수 (null = 무제한) |
onChanged |
ValueChanged<List<String>>? |
null |
칩 목록 변경 콜백 (추가/제거) |
onSubmitted |
ValueChanged<String>? |
null |
Enter로 텍스트 확정 시 콜백 |
pasteSplitter |
List<String> Function(String)? |
null |
붙여넣기를 여러 칩으로 분리. null이면 일반 텍스트 붙여넣기 |
chipInputStyle |
CoreChipInputStyle? |
null |
컨테이너 / 칩 chrome 오버라이드 |
스타일 시스템 (Style System)#
CoreChipInputStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
backgroundColor |
CoreColor? |
Container background fill colour override. |
borderColor |
CoreColor? |
Container border stroke colour override. |
hoverBorderColor |
CoreColor? |
Container border stroke colour override while the pointer is over the frame.
null
→ [defaultHoverBorderColor]. Never applied while the frame holds focus (the focus ring owns that state) or while it is disabled.
|
disabledBackgroundColor |
CoreColor? |
Container fill override while the frame is disabled.
null
→ [defaultDisabledBackgroundColor].
|
disabledBorderColor |
CoreColor? |
Container border stroke colour override while the frame is disabled.
null
→ [defaultDisabledBorderColor].
|
borderWidth |
double? |
Container border stroke width override (logical px). |
borderRadius |
CoreBorderRadius? |
Container border radius override. |
minHeight |
double? |
Container minimum height override (logical px). A stated minimum stays as stated under every density — only [defaultMinHeight], the control row, moves. |
padding |
CoreEdgeInsets? |
Container padding override. |
inputPadding |
CoreEdgeInsets? |
Inset around the inline entry field override. null → [defaultInputPadding]. |
chipSpacing |
double? |
Gap between chip badges override (logical px). |
inputTextStyle |
CoreTextStyle? |
Inline text-entry (
<input>
/
EditableText
) text style override. Typography role + colour are carried in this single [CoreTextStyle] slot (sb-text-style-repackage). Text colour is carried via [CoreTextStyle.color] inside this slot.
|
chipStyle |
CoreChipStyle? |
Nested [CoreChipStyle] slot for chip badge chrome. When set, its matching sub-fields (
backgroundColor
/
labelStyle
/
borderRadius
/
padding
/
closeIconStyle
) take precedence over the corresponding flat
chipXxx
fields above; sub-fields without a matching slot on
CoreChipStyle
(
chipHeight
) keep flowing through the flat fields. The resolver folds
chipStyle.<x>
into the chip chrome before falling back to the flat field or the design-system default.
|
focusOutlineStyle |
CoreFocusOutlineStyle? |
Nested [CoreFocusOutlineStyle] slot for the ring the
frame
draws while the field (or any descendant) holds focus — Flutter wraps the frame in
FocusOutline
, Web paints it with
focus-within:outline-*
. Left null, every ring field falls back to
FocusOutline
's own resolver. Not the
focusOutlineStyle:
inside [chromelessFieldStyle], which belongs to the composed inner
TextField
and
suppresses
its ring — this slot is the visible one.
Deliberately has no default* — absence is the design.
A default here would be this style stating the ring's colour, width and offset on
FocusOutline
's behalf, which is the partial-slot-forward rule's "do not reach into the child's defaults" (
resolver/resolver-pattern.md
). The resolvers state exactly one field underneath this slot — the frame's corner radius, the one value the child cannot derive — and leave the rest null so the child's own resolver fills them.
|
동작 스펙 (Behavior)#
-
모양: 프레임은 텍스트 필드와 같은 계열입니다 — 모서리 12, 쉬는 테두리
outlineVariant1, 포커스는primary링. 안쪽 여백은 위·아래 6, 시작 10, 끝 14 이고(시작·끝은 읽기 방향을 따릅니다), 최소 높이는md행입니다. 칩이 넘치면 여러 줄로 이어지고 모서리는 그대로입니다 -
칩: 합성한
Chip의sm행을filled·low로 그립니다(surfaceContainerHigh면, 모서리 6,Label/Small글자,onSurfaceVariant색). 칩마다 16×16ghost지우기 버튼에 12 크기x글리프가 붙습니다. 칩은 선택 상태를 갖지 않으므로filled+low조합이 안전합니다 -
입력 칸: 안쪽
TextField는plain변형이라 두 번째 테두리·면이 없고, 마지막 칩과 4 떨어져 시작합니다 -
칩 추가 요청: Enter는 trim한 비어 있지 않은 문자열로
onSubmitted(text)를 호출하고 입력을 비웁니다. 컴포넌트가chips에 자동 추가하지 않으므로 부모가 새 목록을 전달해야 합니다 - 칩 제거: 칩의 X 버튼 클릭 → 해당 칩 제거된 목록으로
onChanged호출 - maxChips: 칩 개수가 한도에 도달하면 입력 필드를 숨김
-
붙여넣기:
pasteSplitter가 있으면 trim·빈 항목 제거·남은 개수 한도를 적용한 다음 목록으로onChanged를 호출합니다. 일반 입력 제출과 달리onSubmitted를 항목마다 부르지 않습니다 -
비활성: 입력을 비활성으로 만들고 칩의 제거 버튼을 생략합니다. 프레임은 불투명도가 아니라
disabledContainer채움과disabledOutline테두리로, 칩과 입력 글자는disabledContent로 그립니다.chips를 변경하는 작업은 항상 호출부의 책임입니다 -
호버: 포인터가 프레임(칩·입력 필드 위 포함)에 올라가면 테두리가
outlineVariant에서outline으로 한 단계 진해집니다(hoverBorderColor). 우선순위는disabled > focus > hover > rest— 포커스된 프레임은 포커스 링이 상태 표시이므로 테두리를 평상시 색 그대로 두고, 비활성 프레임에는 호버 단계가 없습니다
사용 가이드라인 (Usage Guidelines)#
✅ Do#
onSubmitted에서 직접 칩 목록에 추가
ChipInput(
chips: tags,
onChanged: (chips) => setState(() => tags = chips),
onSubmitted: (text) => setState(() => tags = [...tags, text]),
)
onSubmitted는 Enter로 확정된 텍스트만 넘겨줄 뿐 chips 목록에 자동으로 추가하지 않습니다. 새 칩을 반영하려면 onSubmitted 콜백 안에서 직접 chips 상태를 갱신해야 합니다.
❌ Don't#
onChanged만으로 추가·제거를 모두 처리한다고 가정하지 않기
// ❌ onChanged 만 등록 — Enter로 입력한 칩은 절대 추가되지 않음
ChipInput(
chips: tags,
onChanged: (chips) => setState(() => tags = chips),
)
onChanged는 칩 제거(X 버튼 클릭) 시에만 호출되고, 칩 추가는 오직 onSubmitted를 통해서만 알립니다. 둘 다 등록하지 않으면 제거는 되는데 추가는 안 되는(또는 그 반대) 상태가 됩니다.
접근성 (Accessibility)#
역할#
- Web — 컨테이너
role="group"+aria-label="Chip input". - Flutter — 컨테이너 role 없음.
-
제거 버튼 (양 플랫폼) — 칩의 텍스트를 넣은 이름을 갖습니다(영어
Remove {chip}, 한국어{chip} 제거). 활성 로케일에서 렌더 시점에 읽습니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Enter (입력 필드) | 칩 추가 | 칩 추가 |
Enter / Space (제거 버튼) | 해당 칩 제거 | 해당 칩 제거 |
Backspace 로 마지막 칩 제거 | 없음 | 없음 |
ArrowLeft/ArrowRight 로 칩 간 이동 |
없음 | 없음 |
칩 사이를 오가는 조작이 없어, 중간 칩을 지우려면 Tab 으로 그 제거 버튼까지
이동해야 합니다.
Flutter 의 Enter 는 명시적 키 핸들러가 아니라 텍스트 필드의 제출 콜백을 탑니다.
Web 은 keydown 을 직접 처리하며 IME 조합 중 확정을 걸러내는 가드가 있고, Flutter
쪽에는 그 가드 코드가 없습니다.
스크린 리더#
양 플랫폼이 제거 버튼마다 어떤 칩을 지우는지 말합니다. Web 은 컨테이너 이름도 내보냅니다. 칩이 추가·제거된 사실을 알리는 live region 은 양쪽 다 없습니다 — 조작 결과가 통보되지 않습니다.
포커스 관리#
- 진입 — 마운트 시 포커스를 옮기지 않습니다. Flutter 에만 컨테이너를 탭하면 입력 필드로 포커스를 넘기는 경로가 있습니다.
- 이탈 / 트랩 — 없음. 포커스는 컨테이너 밖으로 자유롭게 나갑니다.
- 칩을 제거하면 그 버튼이 사라지므로 포커스를 잃습니다 — 다음 칩으로 옮겨주는 코드가 없습니다.
알려진 제약#
-
제거 버튼은 16×16 으로 그려지지만, 포인터는 최소 24 까지 받습니다(양 플랫폼).
그리는 크기는 그대로 두고 닿는 범위만 넓힙니다. 다만 버튼이 칩의 끝에 있어 그 확장은
안쪽(라벨 쪽)으로만 닿습니다 — 바깥쪽은 칩 박스가 끝나는 자리라 어떤 하한도 그
너머로 뻗지 못합니다. 그리는 크기를 키우려면
closeButtonStyle.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | ChipInput | ChipInput |
| 입력 필드 | EditableText | 네이티브 <input> |
| 포커스 링 | FocusOutline |
:focus-within outline |
| 호버 테두리 | MouseRegion 이 호버를 감지하고 resolver 가 푼 hoverBorderColor 를 고름 |
토큰이면
hover:border-*
클래스(
focus-within:hover:
로 평상시 테두리를 되살려 포커스 우선), 토큰이 아닌 색은
:not(:focus-within):hover
scoped 규칙
|
| 입력 버퍼 | 내부 TextEditingController | 내부 TextField의 DOM 입력값 |
| 붙여넣기 인터셉트 | PasteTextIntent / Clipboard | DOM paste 이벤트 |
| IME Enter | 텍스트 입력 프레임워크 제출 | isComposing / keyCode 229이면 제출 안 함 |
관련 컴포넌트 (Related Components)#
- Chip: 단일 칩 표시
- TextField: 단일 라인 텍스트 입력
- Autocomplete: 추천 목록이 있는 입력
-
FormField: 라벨 / 설명 / 에러 텍스트가 필요하면 이 컴포넌트로 감쌉니다 —
ChipInput자신은 라벨 슬롯을 갖지 않습니다
조합 예제#
ChipInput 은 라벨·설명 슬롯을 자체적으로 갖지 않습니다 — 그 둘이 필요하면 컴포넌트를 새로 확장하지 않고 FormField
로 감쌉니다. Checkbox/Toggle/RadioGroup/Select 가 이미 같은 방식으로 라벨/설명을 받습니다. Flutter 는 위
역할 절이 지적한 role 부재도 이 감싸기로 해소됩니다 — FormField 가 컨테이너에 Semantics(container: true, label:, hint:)
를 붙이기 때문입니다.
// 태그 입력 폼 패턴 — label/description 은 FormField 합성으로 받는다
Form(
onSubmit: handleSubmit,
children: [
FormField(
label: '태그',
description: 'Enter로 태그를 추가하세요.',
child: ChipInput(
chips: tags,
placeholder: 'Add a tag...',
onChanged: (chips) => setState(() => tags = chips),
onSubmitted: (text) => setState(() => tags = [...tags, text]),
),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: handleSubmit,
child: Text('제출'),
),
],
)