CoUI ChipInput#
Quick Reference#
- Widget:
ChipInput - Variant: 없음 (variant 파라미터 없음)
- Size: 없음 (size 파라미터 없음)
- Style slot:
chipInputStyle: CoreChipInputStyle(...) - Canonical snippet:
ChipInput(
chips: tags,
placeholder: 'Add a tag...',
onSubmitted: (text) => setState(() => tags = [...tags, text]),
onChanged: (chips) => setState(() => tags = chips),
)
한마디로#
이 기능은 입력칸에 단어를 적고 Enter를 누르면 그 단어가 작은 알약 모양 태그("칩")로 바뀌어 나란히 쌓이는 입력 도구를 만들어 줍니다. 메일 보낼 때 받는 사람을 한 명씩 추가하면 동그란 이름표가 생기는 것, 또는 블로그 글에 해시태그를 여러 개 다는 것을 떠올리면 됩니다. 각 칩에는 X 버튼이 있어 손쉽게 지울 수 있습니다.
무엇을·언제#
- 무엇을: 여러 개의 값(태그, 이메일, 관심 분야 등)을 하나의 입력칸에 차곡차곡 담을 수 있게 해 줍니다. 입력한 값은 각각 지울 수 있는 칩으로 표시됩니다.
- 언제: 태그 입력, 다중 값 입력칸, 이메일 받는 사람 목록처럼 "한 칸에 여러 개를 넣어야 하는" 화면을 만들 때 이 기능이 사용됩니다.
- 옵션: 최대 개수 제한(예: 최대 5개 — 꽉 차면 입력칸이 잠시 사라짐), 안내 문구(placeholder — 현재는 웹 화면에서만 표시됨), 입력 잠금(읽기 전용), 색상·모양 같은 디자인 조정도 할 수 있습니다.
- 어디서: 모바일/앱 화면(Flutter)과 웹 화면(Web)에서 거의 똑같은 방식으로 동작합니다.
핵심 용어#
| 용어 | 쉬운 설명 |
|---|---|
| 칩(Chip) | 입력한 단어 하나가 바뀌어 표시되는 알약 모양의 작은 이름표. X 버튼으로 지울 수 있음 |
| ChipInput | 이런 칩들을 여러 개 담는 입력칸 자체 |
| placeholder | 아직 아무것도 입력하지 않았을 때 칸 안에 흐리게 보이는 안내 문구(예: "태그를 입력하세요") |
| maxChips | 담을 수 있는 칩의 최대 개수 제한 (예: 최대 10개) |
| enabled | 입력칸을 쓸 수 있게 켤지(true), 잠가서 읽기 전용으로 둘지(false) 정하는 값 |
| onSubmitted | 사용자가 Enter를 눌러 새 값을 넣으려 할 때 알려 주는 신호 — 여기서 앱이 값을 검사하고 목록에 추가함 |
| onChanged | 칩이 X 버튼으로 지워져 목록이 바뀌었을 때 새 목록을 알려 주는 신호 |
| Flutter / Web(Jaspr) | 각각 앱 화면과 웹 화면을 만드는 기술. 이 기능은 양쪽 모두에서 동작함 |
| style(스타일) | 입력칸과 칩의 색상·모서리 둥글기·크기 등 겉모습 설정 |
Overview#
ChipInput is a multi-value text input where users type text and press Enter to add chips; each chip carries a remove button. The chip list is
List<String> — the API contract (CoreChipInputContract) is identical on Flutter and Web.
Two behavioral facts drive every usage:
-
Adding is app-side. Pressing Enter with non-empty (trimmed) text fires
onSubmitted(text)and clears the entry field — the widget never appends tochipsitself. Your handler validates and appends. -
Removing is widget-side. Clicking a chip's X fires
onChanged(nextList)with the chip already removed — assign it back to state.
Chrome and dimensional overrides flow through the single chipInputStyle slot (CoreChipInputStyle). There is no
variant, size, or shape enum on this component.
Merge chain:
design system default
→ CoreChipInputTheme.style // 프로젝트 공통
→ parent component slot override
→ widget.chipInputStyle // 인스턴스별
Flutter (coui_flutter)#
Import#
import 'package:coui_flutter/coui_flutter.dart';
The barrel is the only supported import — never import src/ paths.
Basic usage#
ChipInput(
chips: tags,
placeholder: 'Add a tag...',
onSubmitted: (text) => setState(() => tags = [...tags, text]),
onChanged: (chips) => setState(() => tags = chips),
)
onSubmitted receives the trimmed text (empty submits are ignored) and the field auto-clears and refocuses.
onChanged receives the next list after an X-button removal.
With max chips#
When chips.length reaches maxChips, the inline entry field is not rendered — removing a chip brings it back.
ChipInput(
chips: selectedSkills,
maxChips: 5,
onSubmitted: (text) => setState(() => selectedSkills = [...selectedSkills, text]),
onChanged: (chips) => setState(() => selectedSkills = chips),
)
Disabled state#
enabled: false makes the entry field read-only, switches the mouse cursor to basic, and hides every chip's remove button.
ChipInput(
chips: readOnlyTags,
enabled: false,
)
Validation before adding#
ChipInput(
chips: emails,
onSubmitted: (text) {
if (!isValidEmail(text)) return; // ignore invalid — field still clears
setState(() => emails = [...emails, text]);
},
onChanged: (chips) => setState(() => emails = chips),
)
Style override#
ChipInput(
chips: tags,
onSubmitted: (text) => setState(() => tags = [...tags, text]),
onChanged: (chips) => setState(() => tags = chips),
chipInputStyle: const CoreChipInputStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
chipBackgroundColor: CoreColor.token(CoreColors.primary),
chipTextStyle: CoreTextStyle(
color: CoreColor.token(CoreColors.onPrimary),
),
),
)
Flutter parameters#
| Parameter | Type | Default | Description |
|---|---|---|---|
chips |
List<String> |
const [] |
Current chip list (controlled) |
placeholder |
String? |
null |
Contract-parity field — not rendered on Flutter (the inline editable region has no placeholder support); shown on Web only |
enabled |
bool |
true |
Whether the input is interactive; false also hides remove buttons |
maxChips |
int? |
null |
Maximum chips; null = unlimited; at the cap the entry field disappears |
onChanged |
ValueChanged<List<String>>? |
null |
Fired with the next list when a chip is removed via its X button |
onSubmitted |
ValueChanged<String>? |
null |
Fired on Enter with non-empty trimmed text; append the chip here |
chipInputStyle |
CoreChipInputStyle? |
null |
Per-instance chrome/dimensional style override |
Rendering details: chips and the entry field lay out in a Wrap; the focus ring is a FocusOutline
following the container radius; each remove affordance is a plain-variant Button hosting an
Icon(LucideIcons.x); the entry field keeps a fixed 80px minimum width floor (not a token).
Web / Jaspr (coui_web)#
Import#
import 'package:coui_web/coui_web.dart';
Basic usage#
ChipInput(
chips: tags,
placeholder: 'Add a tag...',
onSubmitted: (text) => setState(() => tags = [...tags, text]),
onChanged: (chips) => setState(() => tags = chips),
)
Same add/remove split as Flutter: Enter → onSubmitted (input value trimmed, field cleared), chip X →
onChanged with the next list.
With max chips and DOM id#
ChipInput(
id: 'skill-input',
chips: selectedSkills,
placeholder: 'Add a skill...',
maxChips: 5,
onSubmitted: (text) => setState(() => selectedSkills = [...selectedSkills, text]),
onChanged: (chips) => setState(() => selectedSkills = chips),
)
Web-only constructor extras#
| Parameter | Type | Description |
|---|---|---|
id |
String? |
DOM id on the root <div> |
classes |
String? |
Extra classes appended after the resolved class string |
css |
Styles? |
Extra inline styles combined after the resolved inline styles |
attributes |
Map<String, String>? |
Extra DOM attributes on the root |
eventHandlers |
Map<String, List<UiEventHandler>>? |
Extra DOM event handlers |
The component also provides copyWith(...). All shared parameters (chips, placeholder,
enabled, maxChips, onChanged, onSubmitted, chipInputStyle) are identical to Flutter.
Web callback type#
On Web, callbacks use CoreValueChanged<T> instead of Flutter's ValueChanged<T>
— the handler bodies are written identically:
// Web
onChanged: (List<String> chips) { ... } // CoreValueChanged<List<String>>
onSubmitted: (String text) { ... } // CoreValueChanged<String>
DOM / accessibility#
< div role= " group " aria-label= " Chip input " > // flex-wrap container, focus-within ring
< span > …chip label… < /span > // per chip; X button gets aria-label= " Remove {label} "
< input type= " text " placeholder= " … " > // rendered only while below maxChips
< /div >
Disabled state emits the disabled attribute on the <input> plus cursor-not-allowed opacity-50
on the root, and remove buttons are not rendered.
Variant / Size#
This component has no semantic enums — the full set is empty by design:
| Axis | Enum | Values |
|---|---|---|
| variant | — (none) | n/a |
| size | — (none) | n/a |
| shape | — (none) | n/a |
All appearance control goes through the single style slot chipInputStyle: CoreChipInputStyle.
Style reference — CoreChipInputStyle#
All 17 constructor fields are optional; pass only what you override. merge and copyWith
are provided; the design-system defaults are exposed as CoreChipInputStyle.default* statics.
Container fields#
| Field | Type | Default (static) |
|---|---|---|
backgroundColor |
CoreColor? |
CoreColors.surface |
borderColor |
CoreColor? |
CoreColors.outlineVariant |
focusRingColor |
CoreColor? |
CoreColors.primary |
borderWidth |
double? |
CoreStrokeWidth.stroke1 |
borderRadius |
CoreBorderRadius? |
CoreBorderRadius.all(CoreRadius.radius4) |
minHeight | double? | CoreSize.size40 |
padding |
CoreEdgeInsets? |
symmetric(horizontal: CoreSpace.space12, vertical: CoreSpace.space8) |
chipSpacing | double? | CoreSpace.space6 |
Chip badge fields#
| Field | Type | Default (static) |
|---|---|---|
chipBackgroundColor |
CoreColor? |
CoreColors.secondary |
chipTextStyle |
CoreTextStyle? |
labelSmall role token + medium weight + CoreColors.onSecondary |
chipBorderRadius |
CoreBorderRadius? |
CoreBorderRadius.all(CoreRadius.radius9999) (pill) |
chipHeight | double? | CoreSpace.space24 |
chipPadding |
CoreEdgeInsets? |
symmetric(horizontal: CoreSpace.space8, vertical: CoreSpace.space2) |
chipCloseIconStyle |
CoreIconStyle? |
CoreIconStyle(size: CoreSize.size12) |
Entry field + nested slots#
| Field | Type | Default (static) |
|---|---|---|
inputTextStyle |
CoreTextStyle? |
bodySmall role token + CoreColors.onSurface |
removeButtonStyle |
CoreButtonStyle? |
CoreButtonStyle(hoverForegroundColor: error token, padding: zero) |
chipStyle | CoreChipStyle? | null |
Text colors are carried inside the CoreTextStyle slots (chipTextStyle.color
/ inputTextStyle.color) — there are no flat text-color fields.
Nested removeButtonStyle slot#
The chip remove affordance is a real plain-variant Button; removeButtonStyle
is recursively merged onto the default and raw-forwarded to it. The remove-hover color lives here:
chipInputStyle: const CoreChipInputStyle(
removeButtonStyle: CoreButtonStyle(
hoverForegroundColor: CoreColor.token(CoreColors.errorContainer),
),
),
Nested chipStyle slot#
chipStyle: CoreChipStyle(...) is a convenience slot. Its matching sub-fields — backgroundColor,
labelStyle, borderRadius, padding, closeIconStyle
— take precedence over the corresponding flat chipXxx fields. Sub-fields without a CoreChipStyle
counterpart (chipHeight) always flow through the flat fields.
chipInputStyle: const CoreChipInputStyle(
chipStyle: CoreChipStyle(
backgroundColor: CoreColor.token(CoreColors.primary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius4),
),
),
Fixed (non-overridable) chrome#
These route through default* statics only — there is no constructor field for them:
-
defaultCursorBackgroundColor(outlinetoken) — Flutter-only iOS-style background cursor of the inline editable region. -
defaultPlaceholderColor(onSurfaceVarianttoken) — Web-onlyplaceholder:text class (Flutter renders no placeholder). - Entry-field minimum width: 80px usability floor (both platforms, not a token).
Project-wide theme#
CoreChipInputTheme has a single slot — style. Register it on the app theme's
coreComponentTheme.chipInput slot (CoreComponentTheme); both platform resolvers read
theme.coreComponentTheme?.chipInput.
CoreChipInputTheme(
style: CoreChipInputStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
),
)
Common patterns#
Tag input (unlimited, dedupe)#
ChipInput(
chips: tags,
placeholder: 'Enter a tag and press Enter',
onSubmitted: (text) {
if (tags.contains(text)) return;
setState(() => tags = [...tags, text]);
},
onChanged: (chips) => setState(() => tags = chips),
)
Email recipients (max 10, validated)#
ChipInput(
chips: recipients,
placeholder: 'Add email...',
maxChips: 10,
onSubmitted: (text) {
final isEmail = RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(text);
if (!isEmail) return;
setState(() => recipients = [...recipients, text]);
},
onChanged: (chips) => setState(() => recipients = chips),
)
Flutter ↔ Web Differences#
| Aspect | Flutter | Web (Jaspr) |
|---|---|---|
| Widget base |
StatefulWidget
,
CoreChipInputContract<ValueChanged<List<String>>?, ValueChanged<String>?>
|
UiComponent
,
CoreChipInputContract<CoreValueChanged<List<String>>?, CoreValueChanged<String>?>
|
| Callback type | ValueChanged<T> |
CoreValueChanged<T> |
| Extra ctor params | — |
id
,
classes
,
css
,
attributes
,
eventHandlers
; also
copyWith(...)
|
placeholder |
accepted but not rendered (contract parity only) | rendered as the <input> placeholder attribute |
| Rendering | Wrap of chip Containers + inline editable region |
<div role="group">
with
<span>
chips +
<input type="text">
|
| Focus ring | FocusOutline widget tracking focus state |
focus-within:ring-* Tailwind classes |
| After submit | field clears and refocuses | field clears (focus stays in the input naturally) |
| Disabled | read-only field, basic cursor, remove buttons hidden | disabled attr, cursor-not-allowed opacity-50, remove buttons hidden |
| A11y | focus outline + widget semantics |
role="group"
,
aria-label="Chip input"
, per-chip
aria-label="Remove {label}"
|
Shared on both platforms: List<String> chip model, Enter-to-submit with trim + empty-ignore,
maxChips hides the entry field, remove button is a plain-variant Button with
Icon(LucideIcons.x), single chipInputStyle slot with identical resolution order.
Pitfalls#
-
Adding a chip is entirely the caller's job — pressing Enter only fires
onSubmitted; the widget never appends tochipsitself, and (unlike older docs implied)onChangedfires only on removal, never on add. -
placeholderis accepted on Flutter for contract parity but is never rendered there — the inline editable region has no placeholder support; it only shows up on Web. -
Reaching
maxChipsdoesn't just disable input — the entry field disappears from the layout entirely; removing a chip brings it back. -
enabled: falsealso hides every chip's remove button (X), not just making the entry field read-only. -
chipStyle: CoreChipStyle(...)sub-fields (backgroundColor,labelStyle,borderRadius,padding,closeIconStyle) silently take precedence over the corresponding flatchipXxxfields, butchipHeighthas noCoreChipStylecounterpart and always flows through the flat field only. -
There's no flat text-color field for chips or the entry field — color must be set via
chipTextStyle.color/inputTextStyle.colornested insideCoreTextStyle.
v0.127 Migration (Legacy)#
v0.127 is a de-prefix hard break: the widget class was renamed and no Co-prefixed alias remains.
Core* contract names (CoreChipInputStyle, CoreChipInputTheme,
CoreChipInputContract, CoreChipStyle, CoreButtonStyle) are unchanged.
| ❌ Legacy (pre-0.127, removed) | ✅ v0.127 |
|---|---|
❌ CoChipInput(chips: …) | ChipInput(chips: …) |
❌ CoLucideIcons.x (close glyph) | LucideIcons.x |
❌ CoreChipInputStyle(chipRemoveHoverColor: …) flat field |
CoreChipInputStyle(removeButtonStyle: CoreButtonStyle(hoverForegroundColor: …)) |
Additional migration notes:
-
❌ Older docs suggested
onChangedalso fires when a chip is added — in v0.127 the widget only firesonChangedon removal; adding is always youronSubmittedhandler's job. -
❌ Pre-0.97 shadcn-era generic classes (
ChipInput<T>,ControlledChipInput<T>,ChipInputController<T>,ChipWidgetBuilder<T>) are long gone — the v0.127ChipInputis non-generic with aList<String>model and no controller. -
Import stays the single barrel (
package:coui_flutter/coui_flutter.dart/package:coui_web/coui_web.dart).