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

MultiAnswerCard

선택 상태를 표시하는 카드형 답안 선택 컴포넌트

목차

퀴즈·설문처럼 답안을 카드 형태로 보여주고 선택 상태를 강조하는 컴포넌트입니다. 불투명한 [value]를 가지며, selected 상태에 따라 배경 색이 달라지고 오른쪽 체크박스가 선택을 표시합니다. 테두리 두께는 모든 상태에서 1 로 같아, 선택해도 카드 높이가 변하지 않습니다.

기본 사용법 (Basic Usage)#

MultiAnswerCard<String>(
  value: 'a',
  selected: picked.contains('a'),
  onChanged: (v) => setState(() => toggle(v)),
  child: const Text('Answer A'),
)
MultiAnswerCard<String>(
  value: 'a',
  selected: picked.contains('a'),
  onChanged: (v) => setState(() => toggle(v)),
  child: const Text('Answer A'),
)

빠른 오버라이드 (Chain)#

이미 만든 MultiAnswerCard 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius16처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius16 == CoreRadius.radius16) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class MultiAnswerCardChainExample extends StatefulWidget {
  const MultiAnswerCardChainExample({super.key});

  @override
  State<MultiAnswerCardChainExample> createState() => _MultiAnswerCardChainExampleState();
}

class _MultiAnswerCardChainExampleState extends State<MultiAnswerCardChainExample> {
  final Set<String> _selected = {'a'};

  void _toggle(String value) {
    setState(() {
      if (_selected.contains(value)) {
        _selected.remove(value);
      } else {
        _selected.add(value);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        MultiAnswerCard<String>(
          value: 'a',
          selected: _selected.contains('a'),
          onChanged: _toggle,
          child: const Text('Answer A'),
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor·borderWidth)까지 한 번에.
        MultiAnswerCard<String>(
          value: 'b',
          selected: _selected.contains('b'),
          onChanged: _toggle,
          child: const Text('Answer B'),
        ).withStyle(
          const CoreMultiAnswerCardStyle(
            backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            borderWidth: CoreStrokeWidth.stroke2,
          ),
        ),
      ],
    );
  }
}
class MultiAnswerCardChainExample extends StatefulComponent {
  const MultiAnswerCardChainExample({super.key});

  @override
  State<MultiAnswerCardChainExample> createState() => _MultiAnswerCardChainExampleState();
}

class _MultiAnswerCardChainExampleState extends State<MultiAnswerCardChainExample> {
  final Set<String> _selected = {'a'};

  void _toggle(String value) {
    setState(() {
      if (_selected.contains(value)) {
        _selected.remove(value);
      } else {
        _selected.add(value);
      }
    });
  }

  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        MultiAnswerCard<String>(
          value: 'a',
          selected: _selected.contains('a'),
          onChanged: _toggle,
          child: Text('Answer A'),
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor·borderWidth)까지 한 번에.
        MultiAnswerCard<String>(
          value: 'b',
          selected: _selected.contains('b'),
          onChanged: _toggle,
          child: Text('Answer B'),
        ).withStyle(
          const CoreMultiAnswerCardStyle(
            backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            borderWidth: CoreStrokeWidth.stroke2,
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

Live Preview#

사용 시기 (When to Use)#

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

  • 답안 항목을 카드로 보여주고 선택 여부를 강조해야 하는 경우
  • 다중 선택 설문/퀴즈 UI

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

  • Checkbox: 답안 카드 표면 없이 체크·불확정 값 하나를 표현할 때
  • MultipleChoice: 여러 답안의 개별 selected 값 대신 옵션 목록에서 값 하나를 선택할 때. 다중 집합을 관리하는 컴포넌트는 아님

Props / Parameters#

T 는 답안 값, W 는 Flutter의 Widget / Web의 Jaspr Component입니다.

속성타입기본값설명
valueT—카드가 나타내는 값 (필수)
selectedboolfalse선택 상태
enabledbooltrue상호작용 가능 여부
onChanged void Function(T)? null 카드 탭 시 콜백 (value 전달)
childW필수카드 내용
multiAnswerCardStyle CoreMultiAnswerCardStyle? null chrome 슬롯 (아래 표)

CoreMultiAnswerCardStyle 필드#

필드타입설명
backgroundColor CoreColor? Card background colour override (unselected).
selectedBackgroundColor CoreColor? Card background colour override (selected).
borderColor CoreColor? Card border colour override (unselected).
selectedBorderColor CoreColor? Card border colour override (selected).
hoverColor CoreColor? Card hover background colour override.
hoverBorderColor CoreColor? Card border colour override while hovered (enabled, unselected).
disabledBackgroundColor CoreColor? Card background colour override when disabled.
disabledBorderColor CoreColor? Card border colour override when disabled.
borderWidth double? Card border width override (unselected, logical px).
selectedBorderWidth double? Card border width override (selected, logical px).
borderRadius CoreBorderRadius? Card border radius override.
padding CoreEdgeInsets? Card content padding override.
transitionDuration Duration? Transition duration override for selected / hover state changes; null → [defaultTransitionDuration].
bodyTextStyle CoreTextStyle? Body text style for the caller-supplied child widget (typography role + colour in one CoreTextStyle ). null defers to [defaultBodyTextStyle] — the design-system base typography, so the card text matches the Web side's inherited bodyMedium base.
selectedBodyTextStyle CoreTextStyle? Body text style override while selected (enabled). null defers to [defaultSelectedBodyTextStyle].
disabledBodyTextStyle CoreTextStyle? Body text style override when disabled. null defers to [defaultDisabledBodyTextStyle].
checkboxGapStyle CoreGapStyle? Gap between the answer content and the trailing checkbox. Merged on top of [defaultCheckboxGapStyle] and raw-forwarded to Gap .
checkboxStyle CoreCheckboxStyle? Nested [CoreCheckboxStyle] slot for the composed trailing Checkbox . Merged on top of [defaultCheckboxStyle] and raw-forwarded — the Checkbox's own resolver fills the rest.
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the composed Clickable (press scale / durations / focus ring / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.

동작 스펙 (Behavior)#

인터랙션#

  • 클릭/탭: onChanged에 value 전달
  • 선택 상태: primary-container 면 + primary 테두리(두께 1 고정) + 오른쪽 체크박스(lg) 체크. 글자는 같은 15 크기의 중간 굵기(labelXl, 줄 높이는 기본과 같게 유지)로 바뀝니다
  • 호버: 미선택 카드는 hover 배경색과 outline 테두리 적용
  • 레이아웃: 가로 배치 — 내용이 남는 폭을 차지하고, 간격 checkboxGapStyle(기본 12) 뒤에 체크박스가 가운데 정렬로 놓입니다. 체크박스는 선택을 보여줄 뿐이며 탭은 카드 전체가 받습니다
  • 크기: 카드 높이는 선택 · 호버 · 비활성에서 모두 같습니다 (테두리 1 고정, 선택 글자의 줄 높이 유지)
  • enabled가 false이면 비활성 (커서 forbidden) — 불투명도로 흐리지 않고 disabled-container 면 · disabled-outline 테두리 · disabled-content 글자로 그립니다
  • 활성화는 값만 onChanged로 전달하고 selected를 자동 토글하지 않습니다. 부모가 답안 집합과 선택 표시를 갱신해야 합니다. 콜백이 없어도 enabled가 참이면 표면은 활성화 대상이지만 선택 결과를 저장하지 않습니다.

사용 가이드라인 (Usage Guidelines)#

✅ Do#

selected 는 호출자가 계산해서 전달

MultiAnswerCard<String>(
  value: option.id,
  selected: picked.contains(option.id),
  onChanged: (v) => setState(() => toggle(v)),
  child: Text(option.label),
)

value 는 불투명한 식별자일 뿐입니다 — 카드 자신은 다른 카드의 선택 상태를 모르므로, selected 는 항상 호출자가 공유 상태(picked)에서 계산해 넘겨야 합니다.


❌ Don't#

읽기 전용 카드에 enabled: false를 명시하지 않고 넘어가지 않기

// ❌ onChanged 만 생략 — enabled 기본값(true)이라 hover/press 피드백이 그대로 나타남
MultiAnswerCard<String>(
  value: 'a',
  selected: true,
  child: const Text('Answer A'),
)

enabled 기본값은 true 이므로 onChanged 를 생략해도 hover 배경과 Clickable 의 press/focus 피드백은 계속 나타나 상호작용 가능한 것처럼 보입니다. 읽기 전용 카드에는 enabled: false 를 명시하세요.

접근성 (Accessibility)#

역할#

플랫폼무엇이 붙는가
Flutter 합성된 Clickable 이 Semantics(button: true, enabled: enabled) 를 얹습니다. 선택 상태를 알리는 시맨틱은 없습니다
Web Clickable 루트 <div> 에 role="button" + aria-pressed="{selected}" . 비활성이면 Clickable 이 aria-disabled="true" 를 얹고 tabindex 를 뺍니다

Web 에서 호출자 attributes 는 컴포넌트가 얹은 값 뒤에 병합되어 이깁니다 — 필요하면 role 과 aria-pressed 를 다른 값으로 덮을 수 있습니다(예: 단일 선택 목록에서 role="radio" + aria-checked).

키보드#

키동작
Tab / Shift+Tab카드로 이동 / 이탈 (카드당 tab stop 한 개)
Enter · SpaceonChanged(value) 호출

활성화는 양쪽 다 합성된 Clickable 이 담당합니다 — Flutter 는 SingleActivator(.enter) / SingleActivator(.space) → ActivateIntent, Web 은 keydown 에서 Enter / ' ' / Spacebar 를 받습니다. enabled: false 면 두 키 모두 무시되고 Web 은 tab 순서에서도 빠집니다. 카드 사이를 오가는 화살표 키는 없습니다 — 여러 장을 나열해도 컴포넌트가 그룹을 만들지 않습니다.

스크린 리더#

  • 접근 가능한 이름의 출처는 child 슬롯의 텍스트뿐입니다. 컴포넌트에 label 파라미터가 없으므로 child 에 텍스트가 없으면 이름도 없습니다.
  • 선택 상태: Web 은 aria-pressed 로 매번 전달합니다. Flutter 는 통보하지 않습니다 — 위젯 파일에 Semantics(selected:) / toggled: 가 없고 Clickable 은 button 과 enabled 만 얹습니다.
  • 비활성 상태는 양쪽 다 전달됩니다 — Flutter Semantics(enabled: false), Web aria-disabled="true".
  • 라이브 리전이 아닙니다. 선택이 바뀌어도 즉시 통보되지 않고, 다음에 그 카드가 읽힐 때 새 상태가 나옵니다.

포커스 관리#

오버레이가 아니라 진입 · 이탈 · 트랩이 모두 없습니다. 카드 하나가 tab stop 하나를 가집니다 — Flutter 는 Clickable 의 FocusableActionDetector + FocusOutline, Web 은 Clickable 이 활성일 때 넣는 tabindex="0" 과 focus ring 입니다. 카드를 N 장 나열하면 tab stop 도 N 개가 되며, roving tabindex 처럼 그룹을 한 정지점으로 접는 경로는 없습니다.

알려진 제약#

  • Flutter 에서 선택 상태가 스크린 리더에 도달하지 않습니다. 선택 여부는 배경 · 보더 색과 오른쪽 체크박스로만 드러나고 시맨틱에는 실리지 않습니다(체크박스는 장식으로 취급되어 시맨틱 · 포커스에서 제외됩니다). 대안: selected 는 호출자가 넘기는 값이므로 같은 값으로 Semantics(selected: picked, child: MultiAnswerCard(…)) 처럼 감싸면 됩니다.

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

항목FlutterWeb
클래스명MultiAnswerCardMultiAnswerCard
전환 애니메이션AnimatedContainerCSS transition-all
호버 MouseRegion mouseenter / mouseleave
선택 의미현재 selected를 시맨틱에 전달하지 않는 결손aria-pressed로 selected 전달
활성화Clickable의 Gesture / Enter / SpaceClickable의 click / Enter / Space
오른쪽 체크박스 IgnorePointer + ExcludeFocus + ExcludeSemantics 로 감싼 Checkbox ( lg ) pointer-events-none + aria-hidden 으로 감싼 Checkbox ( lg )