ChipInput | CoUI
LogoCoUI

ChipInput

칩(태그) 형태로 여러 항목을 입력하고 관리하는 컴포넌트

ChipInput#

텍스트를 입력하고 Enter로 확인하면 칩(태그) 형태로 추가되는 다중 항목 입력 컴포넌트입니다. 각 칩에는 제거 버튼이 있고, 칩 목록은 List<String>이라 Flutter / Web API가 동일합니다.

Live Preview#

사용 시기 (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 입력 필드 안내 문구
enabledbooltrue입력 가능 여부
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 없음. 제거 버튼의 라벨도 없습니다.

키보드#

FlutterWeb
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)#

항목FlutterWeb
클래스명ChipInputChipInput
입력 필드EditableText네이티브 <input>
포커스 링FocusOutline:focus-within ring