CoUI ChipInput#
ํ๋ง๋๋ก (๋น๊ฐ๋ฐ์์ฉ)#
์ด ๊ธฐ๋ฅ์ ์ ๋ ฅ์นธ์ ๋จ์ด๋ฅผ ์ ๊ณ 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.
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).