ChipInput | CoUI
본문으로 건너뛰기
LogoCoUI

ChipInput

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

목차

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

기본 사용법 (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',
    );
  }
}

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 태그 / 키워드처럼 자유 입력 다중 값을 받는 경우
  • 입력한 항목을 칩으로 시각화하고 개별 제거가 필요한 경우

대신 다른 컴포넌트를 사용하세요:

  • ChipGroup: 자유 문자열을 새로 만드는 대신 정해진 항목 집합을 단일·다중 선택할 때
  • Autocomplete: 문자열 목록 대신 입력 문자열 하나에 추천을 적용할 때

Props / Parameters#

속성타입기본값설명
chips List<String> [] 현재 칩 목록
placeholder String? null 입력 필드 안내 문구
enabledbooltrue입력 가능 여부
maxChips int? null 최대 칩 개수 (null = 무제한)
onChanged ValueChanged<List<String>>? null 칩 목록 변경 콜백 (추가/제거)
onSubmitted ValueChanged<String>? null Enter로 텍스트 확정 시 콜백
pasteSplitter List<String> Function(String)? null 붙여넣기를 여러 칩으로 분리. null이면 일반 텍스트 붙여넣기
chipInputStyle CoreChipInputStyle? null 컨테이너 / 칩 chrome 오버라이드

스타일 시스템 (Style System)#

CoreChipInputStyle 필드#

필드타입설명
backgroundColor CoreColor? Container background fill colour override.
borderColor CoreColor? Container border stroke colour override.
hoverBorderColor CoreColor? Container border stroke colour override while the pointer is over the frame. null → [defaultHoverBorderColor]. Never applied while the frame holds focus (the focus ring owns that state) or while it is disabled.
disabledBackgroundColor CoreColor? Container fill override while the frame is disabled. null → [defaultDisabledBackgroundColor].
disabledBorderColor CoreColor? Container border stroke colour override while the frame is disabled. null → [defaultDisabledBorderColor].
borderWidth double? Container border stroke width override (logical px).
borderRadius CoreBorderRadius? Container border radius override.
minHeight double? Container minimum height override (logical px). A stated minimum stays as stated under every density — only [defaultMinHeight], the control row, moves.
padding CoreEdgeInsets? Container padding override.
inputPadding CoreEdgeInsets? Inset around the inline entry field override. null → [defaultInputPadding].
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:outline-* . 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)#

  • 모양: 프레임은 텍스트 필드와 같은 계열입니다 — 모서리 12, 쉬는 테두리 outlineVariant 1, 포커스는 primary 링. 안쪽 여백은 위·아래 6, 시작 10, 끝 14 이고(시작·끝은 읽기 방향을 따릅니다), 최소 높이는 md 행입니다. 칩이 넘치면 여러 줄로 이어지고 모서리는 그대로입니다
  • 칩: 합성한 Chip 의 sm 행을 filled · low 로 그립니다(surfaceContainerHigh 면, 모서리 6, Label/Small 글자, onSurfaceVariant 색). 칩마다 16×16 ghost 지우기 버튼에 12 크기 x 글리프가 붙습니다. 칩은 선택 상태를 갖지 않으므로 filled + low 조합이 안전합니다
  • 입력 칸: 안쪽 TextField 는 plain 변형이라 두 번째 테두리·면이 없고, 마지막 칩과 4 떨어져 시작합니다
  • 칩 추가 요청: Enter는 trim한 비어 있지 않은 문자열로 onSubmitted(text) 를 호출하고 입력을 비웁니다. 컴포넌트가 chips 에 자동 추가하지 않으므로 부모가 새 목록을 전달해야 합니다
  • 칩 제거: 칩의 X 버튼 클릭 → 해당 칩 제거된 목록으로 onChanged 호출
  • maxChips: 칩 개수가 한도에 도달하면 입력 필드를 숨김
  • 붙여넣기: pasteSplitter 가 있으면 trim·빈 항목 제거·남은 개수 한도를 적용한 다음 목록으로 onChanged 를 호출합니다. 일반 입력 제출과 달리 onSubmitted 를 항목마다 부르지 않습니다
  • 비활성: 입력을 비활성으로 만들고 칩의 제거 버튼을 생략합니다. 프레임은 불투명도가 아니라 disabledContainer 채움과 disabledOutline 테두리로, 칩과 입력 글자는 disabledContent 로 그립니다. chips 를 변경하는 작업은 항상 호출부의 책임입니다
  • 호버: 포인터가 프레임(칩·입력 필드 위 포함)에 올라가면 테두리가 outlineVariant 에서 outline 으로 한 단계 진해집니다(hoverBorderColor). 우선순위는 disabled > focus > hover > rest — 포커스된 프레임은 포커스 링이 상태 표시이므로 테두리를 평상시 색 그대로 두고, 비활성 프레임에는 호버 단계가 없습니다

사용 가이드라인 (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".
  • Flutter — 컨테이너 role 없음.
  • 제거 버튼 (양 플랫폼) — 칩의 텍스트를 넣은 이름을 갖습니다(영어 Remove {chip}, 한국어 {chip} 제거). 활성 로케일에서 렌더 시점에 읽습니다.

키보드#

키FlutterWeb
Enter (입력 필드)칩 추가칩 추가
Enter / Space (제거 버튼)해당 칩 제거해당 칩 제거
Backspace 로 마지막 칩 제거없음없음
ArrowLeft/ArrowRight 로 칩 간 이동 없음 없음

칩 사이를 오가는 조작이 없어, 중간 칩을 지우려면 Tab 으로 그 제거 버튼까지 이동해야 합니다.

Flutter 의 Enter 는 명시적 키 핸들러가 아니라 텍스트 필드의 제출 콜백을 탑니다. Web 은 keydown 을 직접 처리하며 IME 조합 중 확정을 걸러내는 가드가 있고, Flutter 쪽에는 그 가드 코드가 없습니다.

스크린 리더#

양 플랫폼이 제거 버튼마다 어떤 칩을 지우는지 말합니다. Web 은 컨테이너 이름도 내보냅니다. 칩이 추가·제거된 사실을 알리는 live region 은 양쪽 다 없습니다 — 조작 결과가 통보되지 않습니다.

포커스 관리#

  • 진입 — 마운트 시 포커스를 옮기지 않습니다. Flutter 에만 컨테이너를 탭하면 입력 필드로 포커스를 넘기는 경로가 있습니다.
  • 이탈 / 트랩 — 없음. 포커스는 컨테이너 밖으로 자유롭게 나갑니다.
  • 칩을 제거하면 그 버튼이 사라지므로 포커스를 잃습니다 — 다음 칩으로 옮겨주는 코드가 없습니다.

알려진 제약#

  • 제거 버튼은 16×16 으로 그려지지만, 포인터는 최소 24 까지 받습니다(양 플랫폼). 그리는 크기는 그대로 두고 닿는 범위만 넓힙니다. 다만 버튼이 칩의 끝에 있어 그 확장은 안쪽(라벨 쪽)으로만 닿습니다 — 바깥쪽은 칩 박스가 끝나는 자리라 어떤 하한도 그 너머로 뻗지 못합니다. 그리는 크기를 키우려면 closeButtonStyle.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
클래스명ChipInputChipInput
입력 필드EditableText네이티브 <input>
포커스 링 FocusOutline :focus-within outline
호버 테두리 MouseRegion 이 호버를 감지하고 resolver 가 푼 hoverBorderColor 를 고름 토큰이면 hover:border-* 클래스( focus-within:hover: 로 평상시 테두리를 되살려 포커스 우선), 토큰이 아닌 색은 :not(:focus-within):hover scoped 규칙
입력 버퍼내부 TextEditingController내부 TextField의 DOM 입력값
붙여넣기 인터셉트PasteTextIntent / ClipboardDOM paste 이벤트
IME Enter텍스트 입력 프레임워크 제출isComposing / keyCode 229이면 제출 안 함
  • Chip: 단일 칩 표시
  • TextField: 단일 라인 텍스트 입력
  • Autocomplete: 추천 목록이 있는 입력
  • FormField: 라벨 / 설명 / 에러 텍스트가 필요하면 이 컴포넌트로 감쌉니다 — ChipInput 자신은 라벨 슬롯을 갖지 않습니다

조합 예제#

ChipInput 은 라벨·설명 슬롯을 자체적으로 갖지 않습니다 — 그 둘이 필요하면 컴포넌트를 새로 확장하지 않고 FormField 로 감쌉니다. Checkbox/Toggle/RadioGroup/Select 가 이미 같은 방식으로 라벨/설명을 받습니다. Flutter 는 위 역할 절이 지적한 role 부재도 이 감싸기로 해소됩니다 — FormField 가 컨테이너에 Semantics(container: true, label:, hint:) 를 붙이기 때문입니다.

// 태그 입력 폼 패턴 — label/description 은 FormField 합성으로 받는다
Form(
  onSubmit: handleSubmit,
  children: [
    FormField(
      label: '태그',
      description: 'Enter로 태그를 추가하세요.',
      child: ChipInput(
        chips: tags,
        placeholder: 'Add a tag...',
        onChanged: (chips) => setState(() => tags = chips),
        onSubmitted: (text) => setState(() => tags = [...tags, text]),
      ),
    ),
    Button(
      variant: CoreButtonVariant.primary,
      onPressed: handleSubmit,
      child: Text('제출'),
    ),
  ],
)