ChipInput#
텍스트를 입력하고 Enter로 확인하면 칩(태그) 형태로 추가되는 다중 항목 입력 컴포넌트입니다. 각 칩에는 제거 버튼이 있고, 칩 목록은 List<String>이라 Flutter / Web API가 동일합니다.
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,
);
}
}
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',
);
}
}
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,
),
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 태그 / 키워드처럼 자유 입력 다중 값을 받는 경우
- 입력한 항목을 칩으로 시각화하고 개별 제거가 필요한 경우
대신 다른 컴포넌트를 사용하세요:
Select: 미리 정의된 옵션 중에서 다중 선택할 때Autocomplete: 단일 값 + 추천 목록이 필요할 때
기본 사용법 (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',
);
}
}
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로 텍스트 확정 시 콜백 |
chipInputStyle |
CoreChipInputStyle? |
null |
컨테이너 / 칩 chrome 오버라이드 |
스타일 시스템 (Style System)#
CoreChipInputStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
backgroundColor |
CoreColor? |
Container background fill colour override. |
borderColor |
CoreColor? |
Container border stroke colour override. |
borderWidth |
double? |
Container border stroke width override (logical px). |
borderRadius |
CoreBorderRadius? |
Container border radius override. |
minHeight |
double? |
Container minimum height override (logical px). |
padding |
CoreEdgeInsets? |
Container padding override. |
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:ring-*
. 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)#
- 칩 추가: 텍스트 입력 후 Enter →
onSubmitted(text)호출, 입력 필드 비움 - 칩 제거: 칩의 X 버튼 클릭 → 해당 칩 제거된 목록으로
onChanged호출 - maxChips: 칩 개수가 한도에 도달하면 입력 필드를 숨김
사용 가이드라인 (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", 각 제거 버튼에aria-label="Remove {chip}". - Flutter — role 없음. 제거 버튼의 라벨도 없습니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Enter (입력 필드) | 칩 추가 | 칩 추가 |
Enter / Space (제거 버튼) | 해당 칩 제거 | 해당 칩 제거 |
Backspace 로 마지막 칩 제거 | 없음 | 없음 |
ArrowLeft/ArrowRight 로 칩 간 이동 |
없음 | 없음 |
칩 사이를 오가는 조작이 없어, 중간 칩을 지우려면 Tab 으로 그 제거 버튼까지
이동해야 합니다.
Flutter 의 Enter 는 명시적 키 핸들러가 아니라 텍스트 필드의 제출 콜백을 탑니다.
Web 은 keydown 을 직접 처리하며 IME 조합 중 확정을 걸러내는 가드가 있고, Flutter
쪽에는 그 가드 코드가 없습니다.
스크린 리더#
Web 은 컨테이너 이름과 제거 버튼 라벨을 내보냅니다. 칩이 추가·제거된 사실을 알리는 live region 은 양쪽 다 없습니다 — 조작 결과가 통보되지 않습니다.
포커스 관리#
- 진입 — 마운트 시 포커스를 옮기지 않습니다. Flutter 에만 컨테이너를 탭하면 입력 필드로 포커스를 넘기는 경로가 있습니다.
- 이탈 / 트랩 — 없음. 포커스는 컨테이너 밖으로 자유롭게 나갑니다.
- 칩을 제거하면 그 버튼이 사라지므로 포커스를 잃습니다 — 다음 칩으로 옮겨주는 코드가 없습니다.
알려진 제약#
-
제거 버튼의 히트 영역이 16×16 입니다(양 플랫폼). 접근성
페이지가 보장하는 최소 24×24(WCAG 2.2 AA)에 미치지 못합니다. 값은 공유 Core 스타일
슬롯에서 오므로
closeButtonStyle로 키울 수 있습니다. - Flutter 는 제거 버튼에 라벨이 없어 리더가 어떤 칩을 지우는지 알 수 없습니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | ChipInput | ChipInput |
| 입력 필드 | EditableText | 네이티브 <input> |
| 포커스 링 | FocusOutline | :focus-within ring |
관련 컴포넌트 (Related Components)#
- Chip: 단일 칩 표시
- TextField: 단일 라인 텍스트 입력
- Autocomplete: 추천 목록이 있는 입력