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#
코드 보기
class MultiAnswerCardDefaultExample extends StatefulComponent {
const MultiAnswerCardDefaultExample({super.key});
@override
State<MultiAnswerCardDefaultExample> createState() =>
_MultiAnswerCardDefaultExampleState();
}
class _MultiAnswerCardDefaultExampleState
extends State<MultiAnswerCardDefaultExample> {
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 Component.element(
tag: 'div',
styles: Styles(raw: {'display': 'flex', 'flex-direction': 'column'}),
children: [
MultiAnswerCard<String>(
value: 'a',
selected: _selected.contains('a'),
onChanged: _toggle,
child: Text('Answer A'),
),
Gap.space8(),
MultiAnswerCard<String>(
value: 'b',
selected: _selected.contains('b'),
onChanged: _toggle,
child: Text('Answer B'),
),
],
);
}
}
class MultiAnswerCardDefaultExample extends StatefulWidget {
const MultiAnswerCardDefaultExample({super.key});
@override
State<MultiAnswerCardDefaultExample> createState() =>
_MultiAnswerCardDefaultExampleState();
}
class _MultiAnswerCardDefaultExampleState
extends State<MultiAnswerCardDefaultExample> {
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,
children: [
MultiAnswerCard<String>(
value: 'a',
selected: _selected.contains('a'),
onChanged: _toggle,
child: const Text('Answer A'),
),
const Gap.space8(),
MultiAnswerCard<String>(
value: 'b',
selected: _selected.contains('b'),
onChanged: _toggle,
child: const Text('Answer B'),
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 답안 항목을 카드로 보여주고 선택 여부를 강조해야 하는 경우
- 다중 선택 설문/퀴즈 UI
대신 다른 컴포넌트를 사용하세요:
- Checkbox: 답안 카드 표면 없이 체크·불확정 값 하나를 표현할 때
- MultipleChoice: 여러 답안의 개별 selected 값 대신 옵션 목록에서 값 하나를 선택할 때. 다중 집합을 관리하는 컴포넌트는 아님
Props / Parameters#
T 는 답안 값, W 는 Flutter의 Widget / Web의 Jaspr Component입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
value | T | — | 카드가 나타내는 값 (필수) |
selected | bool | false | 선택 상태 |
enabled | bool | true | 상호작용 가능 여부 |
onChanged |
void Function(T)? |
null |
카드 탭 시 콜백 (value 전달) |
child | W | 필수 | 카드 내용 |
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 · Space | onChanged(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), Webaria-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)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | MultiAnswerCard | MultiAnswerCard |
| 전환 애니메이션 | AnimatedContainer | CSS transition-all |
| 호버 | MouseRegion |
mouseenter / mouseleave |
| 선택 의미 | 현재 selected를 시맨틱에 전달하지 않는 결손 | aria-pressed로 selected 전달 |
| 활성화 | Clickable의 Gesture / Enter / Space | Clickable의 click / Enter / Space |
| 오른쪽 체크박스 |
IgnorePointer
+
ExcludeFocus
+
ExcludeSemantics
로 감싼
Checkbox
(
lg
)
|
pointer-events-none
+
aria-hidden
으로 감싼
Checkbox
(
lg
)
|
관련 컴포넌트 (Related Components)#
- MultipleChoice: 그룹 단위 선택 관리
- Checkbox: 단순 체크박스