LogoSkills

coui-chip-input

칩 입력, 태그 입력, 다중 값 텍스트 필드, 토큰 필드, 이메일 수신자 입력을 CoUI Flutter(coui_flutter) 또는 CoUI Web(coui_web)에서 ChipInput 위젯과 CoreChipInputStyle, CoreChipInputTheme, CoreChipInputContract, 중첩된 CoreChipStyle/CoreBu...

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:

  1. Adding is app-side. Pressing Enter with non-empty (trimmed) text fires onSubmitted(text) and clears the entry field — the widget never appends to chips itself. Your handler validates and appends.
  2. 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 defaultCoreChipInputTheme.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#

ParameterTypeDefaultDescription
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#

ParameterTypeDescription
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:

AxisEnumValues
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#

FieldTypeDefault (static)
backgroundColor CoreColor? CoreColors.surface
borderColor CoreColor? CoreColors.outlineVariant
focusRingColor CoreColor? CoreColors.primary
borderWidth double? CoreStrokeWidth.stroke1
borderRadius CoreBorderRadius? CoreBorderRadius.all(CoreRadius.radius4)
minHeightdouble?CoreSize.size40
padding CoreEdgeInsets? symmetric(horizontal: CoreSpace.space12, vertical: CoreSpace.space8)
chipSpacingdouble?CoreSpace.space6

Chip badge fields#

FieldTypeDefault (static)
chipBackgroundColor CoreColor? CoreColors.secondary
chipTextStyle CoreTextStyle? labelSmall role token + medium weight + CoreColors.onSecondary
chipBorderRadius CoreBorderRadius? CoreBorderRadius.all(CoreRadius.radius9999) (pill)
chipHeightdouble?CoreSpace.space24
chipPadding CoreEdgeInsets? symmetric(horizontal: CoreSpace.space8, vertical: CoreSpace.space2)
chipCloseIconStyle CoreIconStyle? CoreIconStyle(size: CoreSize.size12)

Entry field + nested slots#

FieldTypeDefault (static)
inputTextStyle CoreTextStyle? bodySmall role token + CoreColors.onSurface
removeButtonStyle CoreButtonStyle? CoreButtonStyle(hoverForegroundColor: error token, padding: zero)
chipStyleCoreChipStyle?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 (outline token) — Flutter-only iOS-style background cursor of the inline editable region.
  • defaultPlaceholderColor (onSurfaceVariant token) — Web-only placeholder: 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#

AspectFlutterWeb (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 to chips itself, and (unlike older docs implied) onChanged fires only on removal, never on add.
  • placeholder is 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 maxChips doesn't just disable input — the entry field disappears from the layout entirely; removing a chip brings it back.
  • enabled: false also 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 flat chipXxx fields, but chipHeight has no CoreChipStyle counterpart 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.color nested inside CoreTextStyle.

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 onChanged also fires when a chip is added — in v0.127 the widget only fires onChanged on removal; adding is always your onSubmitted handler's job.
  • ❌ Pre-0.97 shadcn-era generic classes (ChipInput<T>, ControlledChipInput<T>, ChipInputController<T>, ChipWidgetBuilder<T>) are long gone — the v0.127 ChipInput is non-generic with a List<String> model and no controller.
  • Import stays the single barrel (package:coui_flutter/coui_flutter.dart / package:coui_web/coui_web.dart).