Filter
상호 배타적인 옵션을 토글 버튼 그룹으로 묶는 분절형 필터 컨트롤
목차
여러 옵션을 라디오 그룹처럼 상호 배타적으로 선택하는 분절형 필터 컨트롤입니다. Flutter/Web 양쪽에서 동일한 API(Filter)를 제공합니다.
Filter는 controlled 컴포넌트입니다 — 선택값은 부모가 groupValue로 소유하고, onValueChanged
콜백으로 상태를 갱신합니다. 선택적으로 reset 버튼을 통해 선택을 해제할 수 있습니다.
세그먼트는 ToggleGroup을 합성해 그립니다 — 트랙, 올라온 선택 면과 그림자, 칸 크기, 라벨 글자, 비활성 모양은
ToggleGroup 의 것이고 Filter 는 그 위에 reset 버튼과 구분선만 더합니다.
기본 사용법 (Basic Usage)#
Filter<String>(
groupValue: _selected,
onValueChanged: (value) => setState(() => _selected = value),
onReset: () => setState(() => _selected = null),
showResetButton: true,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
)
Filter<String>(
groupValue: _selected,
onValueChanged: (value) => setState(() => _selected = value),
onReset: () => setState(() => _selected = null),
showResetButton: true,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
)
빠른 오버라이드 (Chain)#
이미 만든 Filter 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius16처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius16 ==
CoreRadius.radius16) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class FilterChainExample extends StatefulWidget {
const FilterChainExample({super.key});
@override
State<FilterChainExample> createState() => _FilterChainExampleState();
}
class _FilterChainExampleState extends State<FilterChainExample> {
String? _selected = 'svelte';
void handleValueChanged(String value) {
setState(() => _selected = value);
}
void handleReset() {
setState(() => _selected = null);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
onReset: handleReset,
showResetButton: true,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
onReset: handleReset,
showResetButton: true,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
).withStyle(
const CoreFilterStyle(
toggleGroupStyle: CoreToggleGroupStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
itemBorderRadius: CoreBorderRadius.all(CoreRadius.radius8),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
),
],
);
}
}
class FilterChainExample extends StatefulComponent {
const FilterChainExample({super.key});
@override
State<FilterChainExample> createState() => _FilterChainExampleState();
}
class _FilterChainExampleState extends State<FilterChainExample> {
String? _selected = 'svelte';
void handleValueChanged(String value) {
setState(() => _selected = value);
}
void handleReset() {
setState(() => _selected = null);
}
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
onReset: handleReset,
showResetButton: true,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
onReset: handleReset,
showResetButton: true,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
).withStyle(
const CoreFilterStyle(
toggleGroupStyle: CoreToggleGroupStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
itemBorderRadius: CoreBorderRadius.all(CoreRadius.radius8),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
),
],
classes: 'flex flex-col items-start',
);
}
}
Live Preview#
코드 보기
class FilterDefaultExample extends StatefulComponent {
const FilterDefaultExample({super.key});
@override
State<FilterDefaultExample> createState() => _FilterDefaultExampleState();
}
class _FilterDefaultExampleState extends State<FilterDefaultExample> {
String? _selected = 'svelte';
void handleValueChanged(String value) {
setState(() => _selected = value);
}
void handleReset() {
setState(() => _selected = null);
}
@override
Component build(BuildContext context) {
return Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
onReset: handleReset,
showResetButton: true,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
);
}
}
class FilterDefaultExample extends StatefulWidget {
const FilterDefaultExample({super.key});
@override
State<FilterDefaultExample> createState() => _FilterDefaultExampleState();
}
class _FilterDefaultExampleState extends State<FilterDefaultExample> {
String? _selected = 'svelte';
void handleValueChanged(String value) {
setState(() => _selected = value);
}
void handleReset() {
setState(() => _selected = null);
}
@override
Widget build(BuildContext context) {
return Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
onReset: handleReset,
showResetButton: true,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 한정된 옵션 중 하나를 빠르게 전환해야 할 때 (프레임워크, 정렬 기준, 카테고리 등)
- 옵션이 항상 화면에 모두 노출되어야 할 때
- 선택 해제(reset) 기능이 필요할 때
대신 다른 컴포넌트를 사용하세요:
- RadioGroup: 라디오 입력의 폼·키보드 계약이 필요할 때
- Tabs: 값 선택 요청뿐 아니라 콘텐츠 패널 전환도 필요할 때
- ChipGroup: 값 하나 대신 선택 집합을 관리해야 할 때
Props / Parameters#
T 는 선택 값, W 는 Flutter Widget / Web Jaspr Component
입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
variant |
CoreFilterVariant |
segmented |
시각 변형 |
size |
CoreComponentSize |
md |
크기 단계. 합성한
ToggleGroup
에 그대로 넘깁니다 —
md
는 트랙 40(칸 32 · reset 버튼 32×32 · 구분선 16),
sm
은 트랙 32(칸 26 · reset 버튼 32×26 · 구분선 14).
sm
이 아닌 다른 값은
md
로 읽습니다. Filter 만의 크기 enum 은 없습니다.
|
groupValue | T? | 필수 | 현재 선택 값 |
items |
List<CoreFilterItem<T, W>> |
필수 | 필터 옵션 목록 |
onValueChanged |
void Function(T) |
필수 | 항목 선택 요청 |
onReset |
void Function()? |
null |
reset 요청 |
showResetButton |
bool |
false |
reset 버튼 표시 |
enabled |
bool? |
null |
null
이면 활성.
false
이면 입력을 받지 않고 비활성 모양(트랙
disabled-container
, 글자
disabled-content
, 선택 칸 그림자 없음)을 그립니다.
|
filterStyle |
CoreFilterStyle? |
null |
스타일 단일 슬롯 |
CoreFilterItem#
CoreFilterItem<T, W>는 세그먼트 하나를 기술합니다. W는 플랫폼 위젯 타입(Flutter Widget
/ Web Component)이며 items: const [CoreFilterItem(value: …, label: …)]처럼 인라인으로 쓰면 타입 추론이 채워 줍니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
value |
T |
— | 이 세그먼트가 나타내는 값. groupValue와 ==로 비교해 선택 여부를 정합니다. |
label |
String |
— |
세그먼트 라벨.
showLabel: false
여도 접근성 이름(
aria-label
/
Semantics(label:)
)으로 남습니다.
|
icon |
W? |
null |
라벨 앞에 그리는 호출자 제공 아이콘. 선택 상태에 따라
iconStyle
/
selectedIconStyle
의 색·크기가 ambient
IconTheme
으로 전달됩니다.
|
showLabel |
bool |
true |
라벨 박스를 그릴지 여부.
icon != null && showLabel == false
이면 아이콘 전용 세그먼트(칸의 최소 폭이 칸 높이라
md
32×32 ·
sm
26×26 정사각)가 됩니다.
|
Filter<String>(
groupValue: _selected,
onValueChanged: (value) => setState(() => _selected = value),
items: [
const CoreFilterItem(value: 'all', label: 'All'),
CoreFilterItem(
value: 'starred',
label: 'Starred',
icon: const Icon(LucideIcons.star),
showLabel: false, // icon-only 32×32 segment
),
],
)
스타일 시스템 (Style System)#
모든 시각 chrome은 단일 filterStyle: CoreFilterStyle? 슬롯으로 들어갑니다. 트랙·칸·선택 면은 합성한 ToggleGroup
의 스타일이라 toggleGroupStyle 슬롯으로 덮고, Filter 가 직접 정하는 것은 reset 버튼(resetButtonStyle)과 구분선(dividerStyle
· dividerHeight)과 그 앞뒤 간격입니다. 마지막 칸에서 구분선까지는 dividerLeadingSpacing(기본 6), 구분선에서 ✕ 버튼까지는
trailingSpacing(기본 2)이며, ✕ 버튼에 자체 여백이 있어 2 만으로도 양쪽이 비슷하게 보입니다. 비활성일 때는 칸과 함께 ✕ 도 disabledContent
색입니다. 예전의 평면 필드(backgroundColor · itemPadding 등)는 deprecated 이며 아직 toggleGroupStyle
아래로 접혀 들어갑니다.
Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
items: items,
filterStyle: const CoreFilterStyle(
toggleGroupStyle: CoreToggleGroupStyle(
backgroundColor: CoreColor.token(CoreColors.surfaceContainerHigh),
selectedColor: CoreColor.token(CoreColors.surfaceRaised),
itemSpacing: CoreSpace.space2,
),
),
)
CoreFilterStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
backgroundColor |
CoreColor? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Background fill of the filter container. |
selectedColor |
CoreColor? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Background fill of the currently selected filter item. |
borderRadius |
CoreBorderRadius? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Container corner radius. |
itemBorderRadius |
CoreBorderRadius? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Per-item corner radius. |
padding |
CoreEdgeInsets? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Padding inside the filter container. |
itemPadding |
CoreEdgeInsets? |
Deprecated (since 0.173)
— Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Padding inside each filter item — the segment's own uniform inset around
[icon][label box]
. Read by both platform resolvers and the Figma builder.
|
labelPadding |
CoreEdgeInsets? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Inset of the label box inside a segment (painted only when the item shows its label). Read by both platform resolvers and the Figma builder. |
itemHeight |
double? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Fixed segment height (logical px); content centres vertically. Read by both platform resolvers and the Figma builder. |
itemSpacing |
double? |
Deprecated (since 0.173)
— Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Spacing between filter items (logical px). Maps to native
Row.spacing
on Flutter and CSS flex
gap
on Web.
|
resetButtonStyle |
CoreButtonStyle? |
Reset (clear-selection) button style override. The single entry point for the reset-affordance chrome — square side (
width
/
height
), padding, corner radius, hover transition (
animationDuration
), rest / hover foreground, and the close glyph (
leadingIconStyle
). Recursively merged onto [defaultResetButtonStyle] and raw-forwarded to
Button(variant:.ghost, size:.sm, shape:.square, buttonStyle: …)
(the
ghost
variant owns the transparent-fill / hover semantics).
|
selectedFontWeight |
int? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Font weight applied to the currently selected item label (CSS font-weight integer). |
transitionDuration |
Duration? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Selection-state colour transition duration. |
labelTextStyle |
CoreTextStyle? |
Deprecated (since 0.173)
— Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Label text style override applied to every unselected filter item label and the reset icon. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw
foregroundColor
field removed).
|
selectedLabelTextStyle |
CoreTextStyle? |
Deprecated (since 0.173)
— Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Label text style override applied to the currently selected item label. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw
selectedForegroundColor
field removed).
|
iconStyle |
CoreIconStyle? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Nested [CoreIconStyle] slot applied to an unselected segment's caller-supplied icon (size + colour). Merged onto [defaultIconStyle] and raw-forwarded as the icon slot's ambient icon theme on both platforms. |
selectedIconStyle |
CoreIconStyle? |
Deprecated (since 0.173) — Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Nested [CoreIconStyle] slot applied to the selected segment's caller-supplied icon. Merged onto [defaultSelectedIconStyle] and raw-forwarded on both platforms. |
clickableStyle |
CoreClickableStyle? |
Deprecated (since 0.173)
— Use toggleGroupStyle — the segments are a composed ToggleGroup now; the value still reaches it until removed. Nested [CoreClickableStyle] slot for the composed per-item
Clickable
(press scale / durations / focus ring / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.
|
toggleGroupStyle |
CoreToggleGroupStyle? |
Nested [CoreToggleGroupStyle] slot for the composed segments. Merged on top of [defaultToggleGroupStyle] (and the deprecated flat fields, see [toggleGroupStyleFor]) and raw-forwarded to
ToggleGroup(toggleGroupStyle:)
— the group's own resolver fills the rest.
|
dividerStyle |
CoreDividerStyle? |
Nested [CoreDividerStyle] slot of the hairline between the segments and the reset button. Merged on top of [defaultDividerStyle] and raw-forwarded to
Divider(dividerStyle:)
.
|
disabledDividerColor |
CoreColor? |
Colour of the hairline while the filter is disabled. Applied on top of [dividerStyle] only when the filter is disabled; falls back to [defaultDisabledDividerColor]. |
dividerHeight |
double? |
Length of the hairline (logical px). |
dividerLeadingSpacing |
double? |
Distance from the last segment to the hairline (logical px, edge to edge). Counted from the segment, not from the group's own item gap. |
trailingSpacing |
double? |
Spacing between the hairline and the reset button (logical px). |
테마 커스터마이징 (Theme)#
CoreFilterTheme으로 프로젝트 레벨 스타일 오버라이드가 가능합니다.
CoreComponentTheme(
filter: CoreFilterTheme(
style: CoreFilterStyle(
toggleGroupStyle: CoreToggleGroupStyle(
backgroundColor: CoreColor.token(CoreColors.surfaceContainerHigh),
),
),
),
)
Resolve 우선순위: 디자인 시스템 기본값 → CoreFilterTheme.style → CoreFilterTheme.variantStyles[variant]
→ 위젯 filterStyle.
변형 (Variants)#
비활성#
enabled: false 로 끕니다. 트랙은 disabled-container, 글자는 disabled-content
이고 선택 칸은 surface-container-high 로 가라앉아 그림자를 그리지 않습니다.
코드 보기
class FilterDisabledExample extends StatefulComponent {
const FilterDisabledExample({super.key});
@override
State<FilterDisabledExample> createState() => _FilterDisabledExampleState();
}
class _FilterDisabledExampleState extends State<FilterDisabledExample> {
String? _selected = 'vue';
void handleValueChanged(String value) {
setState(() => _selected = value);
}
void handleReset() {
setState(() => _selected = null);
}
@override
Component build(BuildContext context) {
// 비활성 — `enabled: false` 로 끈다. 트랙은 disabled-container, 선택 칸은
// 그림자 없이 가라앉고 글자는 disabled-content 이다.
return div([
Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
onReset: handleReset,
showResetButton: true,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
),
const Gap.space12(),
Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
onReset: handleReset,
showResetButton: true,
enabled: false,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
),
]);
}
}
class FilterDisabledExample extends StatefulWidget {
const FilterDisabledExample({super.key});
@override
State<FilterDisabledExample> createState() => _FilterDisabledExampleState();
}
class _FilterDisabledExampleState extends State<FilterDisabledExample> {
String? _selected = 'vue';
void handleValueChanged(String value) {
setState(() => _selected = value);
}
void handleReset() {
setState(() => _selected = null);
}
@override
Widget build(BuildContext context) {
// 비활성 — `enabled: false` 로 끈다. 트랙은 disabled-container, 선택 칸은
// 그림자 없이 가라앉고 글자는 disabled-content 이다.
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
onReset: handleReset,
showResetButton: true,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
),
const Gap.space12(),
Filter<String>(
groupValue: _selected,
onValueChanged: handleValueChanged,
onReset: handleReset,
showResetButton: true,
enabled: false,
items: const [
CoreFilterItem(value: 'svelte', label: 'Svelte'),
CoreFilterItem(value: 'vue', label: 'Vue'),
CoreFilterItem(value: 'react', label: 'React'),
],
),
],
);
}
}
동작 스펙 (Behavior)#
-
groupValue == item.value로 선택을 표시합니다. 항목 활성화는onValueChanged(item.value)를 호출하고 부모가 값을 갱신해야 선택이 바뀝니다. reset도onReset요청만 하며 자체 값을 지우지 않습니다. - 필터링할 데이터·본문 패널을 소유하지 않습니다. 호출부가 목록 필터 결과를 갱신합니다.
enabled: false이면 입력을 받지 않고 reset 버튼도 누를 수 없습니다. 칸 하나만 비활성화하는 플래그는 없습니다.
시각#
-
트랙
surface-container-high, 모서리 10(md) / 8(sm), 안쪽 여백 4(md) / 3(sm) (ToggleGroup과 같은 값) -
선택된 항목은
surface-raised면 +ToggleGroup과 같은 그림자(CoreShadow.xs) + 굵은(semibold) 텍스트로 강조 -
reset 버튼이 보일 때만 항목과 reset 버튼 사이에 1px · 높이 16(
md) / 14(sm)outline-variant구분선 -
비활성: 트랙
disabled-container, 글자disabled-content, 선택 면surface-container-high에 그림자 없음 - 선택 전환 시 150ms 색상 transition
레이아웃#
- 항목들은 가로로 배치되며
itemSpacing간격 유지 -
각 세그먼트는 높이
itemHeight(md32 /sm26) 고정, 최소 폭은 그 높이. 칸 좌우 여백(md2 /sm0) 안에 [아이콘] + [라벨 박스(labelPadding가로 8)]를 가로 · 세로 가운데에 배치 — 아이콘 전용 세그먼트는md32×32 ·sm26×26 정사각, 라벨 전용 세그먼트는md텍스트 폭 + 20 ·sm텍스트 폭 + 16 -
트랙 높이는
md4 + 32 + 4 = 40,sm3 + 26 + 3 = 32 — 같은 줄의 같은 크기 컨트롤과 맞습니다. 밀도는 트랙을 움직이지 않습니다(md는 모든 밀도에서 40) -
showResetButton: true이고onReset != null이면 트랙 안 항목들 뒤에 구분선과 X 아이콘 reset 버튼 표시 —Button(variant: .ghost, size: .sm, shape: .square)(md32×32 ·sm폭 32 × 높이 26)
사용 가이드라인 (Usage Guidelines)#
✅ Do#
선택 해제가 유효한 상태라면 showResetButton과 onReset을 함께 제공
Filter<String>(
groupValue: _selected,
onValueChanged: (v) => setState(() => _selected = v),
onReset: () => setState(() => _selected = null),
showResetButton: true,
items: items,
)
리셋 아이콘 버튼은 showResetButton과 onReset이 모두 있어야만 렌더링됩니다 — 하나만 설정하면 리셋 기능이 화면에 나타나지 않습니다.
❌ Don't#
다중 선택에 Filter를 사용하지 않기
// ❌ Filter 는 controlled 단일 선택 전용 — 여러 값을 동시에 선택할 수 없음
Filter<String>(
groupValue: _selected, // T? — 단일 값만 보유
onValueChanged: (v) => setState(() => _selected = v),
items: items,
)
groupValue는 T? 단일 값이라 라디오 그룹처럼 상호 배타적으로만 동작합니다. 다중 선택이 필요하면 ChipGroup을 사용하세요.
접근성 (Accessibility)#
역할#
-
Web: 합성한
ToggleGroup의 트랙이role="radiogroup", 각 항목이role="radio"+aria-checked+aria-label=item.label입니다. 비활성이면 그룹에aria-disabled="true"가 붙고 항목은 탭 정지점이 없습니다. 폭 고정용 고스트 라벨은aria-hidden="true"로 빠집니다. 리셋 버튼은aria-label=filterResetLabel입니다. -
Flutter: 각 항목이
Semantics(checked:, inMutuallyExclusiveGroup: true, enabled:, label:)입니다. 합성한Clickable은 버튼 역할을 내려놓습니다. 고스트 라벨은Visibility(visible: false)의 기본maintainSemantics: false로 트리에서 빠집니다. 리셋 버튼은Semantics(button: true, label: filterResetLabel)로 이름이 있습니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Tab |
리셋 버튼과 모든 항목에 하나씩 정지 | 리셋 버튼 + 그룹의 항목 하나(선택된 항목, 없으면 첫 항목) |
Enter / Space |
포커스된 항목 선택 / 리셋 실행 | 포커스된 항목 선택 / 리셋 실행 |
← / ↑, → / ↓ |
프레임워크 방향성 포커스 이동 (선택은 바꾸지 않음) | 이전/다음 항목으로 이동하고 선택, 끝에서 순환 |
Home / End | 없음 | 첫/마지막 항목으로 이동하고 선택 |
리셋 버튼은 항목들 뒤에 렌더되므로 Tab 순서상 마지막 정지점입니다.
Web 은 WAI-ARIA radio group 패턴의 roving tabindex 를 구현합니다. 화살표·
Home/End 는 포커스뿐 아니라 선택도 바꿉니다. Flutter 의 방향성 순회는
프레임워크 폴백이고 라디오 그룹의 선택 이동이 아닙니다.
브라우저 검증 — Web 키보드 프로브(
tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제 포커스 이동 결과를 확인합니다.
스크린 리더#
각 항목의 접근 가능한 이름은 item.label 입니다 (Web aria-label, Flutter
Semantics(label:)). showLabel: false 인 아이콘 전용 세그먼트도 이름은 유지됩니다.
선택 상태는 Web aria-checked, Flutter Semantics(selected:)
로 전달됩니다.
리셋 버튼의 이름은 로컬라이즈된 filterResetLabel(영문 "Reset filter", 한국어
"필터 초기화")입니다.
그룹 자체는 이름이 없습니다 — 어느 플랫폼도 루트에 라벨을 붙이지 않습니다. Web 은 role="radiogroup" 을 이름 없이 내보내고 Flutter 는 그룹 노드가 아예 없어서, "무엇을 고르는 필터인가" 가 전달되지 않습니다.
live region 은 없습니다. 선택 변경은 포커스가 그 항목에 있을 때 상태 변화로만 전달되고, 별도 통보는 발생하지 않습니다.
포커스 관리#
오버레이가 아니므로 진입·이탈 시 포커스 이동이 없고 트랩도 없습니다. 포커스 표시는 합성한 Clickable 의 기본 링이 항목마다 그립니다. 선택이 바뀌어도 포커스는 방금 활성화한 항목에 그대로 남습니다 — 두 구현 모두
onValueChanged 만 호출하고 포커스를 옮기지 않습니다.
알려진 제약#
-
Flutter 는 항목마다 tab stop 이 생깁니다. Web 의 radio group 키보드 패턴과
달리 화살표가 선택을 바꾸지 않습니다. 항목을 선택하려면
Enter/Space를 씁니다. -
그룹에 이름을 줄 방법이 Web 에 없습니다 —
Filter생성자가super.attributes/super.classes/super.css를 받지 않아aria-label/aria-labelledby를 루트에 실을 진입점이 없습니다. 대안: Flutter 는 호출부가Semantics(container: true, label: …)로 감쌀 수 있고, Web 은 필터 옆에 보이는 제목 텍스트를 배치하는 우회가 전부입니다. -
개별 항목을 비활성화하거나 이름을 따로 줄 수 없습니다 —
CoreFilterItem의icon/showLabel은 표시를 조절하지만 비활성 상태나 별도 접근성 이름을 받지 않습니다. 고를 수 없는 값은items에서 제외합니다.
이 컴포넌트가 직접 다루지 않는 대비·모션 등 전역 축은 전역 접근성 축 에서 한 곳으로 관리됩니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 선택 의미 | 각 Clickable의 label·selected 시맨틱 | radiogroup / radio · aria-checked |
| 키보드 | 항목별 Tab · Enter / Space | roving tabindex · 양 축 화살표 · Home / End로 선택 이동 |
| 색 전환 | AnimatedContainer | CSS transition |
| 폭 유지 | 숨긴 semibold 텍스트를 크기만 유지 | aria-hidden 라벨 ghost |
관련 컴포넌트 (Related Components)#
- RadioGroup: 폼 안의 세로 라디오 선택
- Tabs: 콘텐츠 패널 전환
- ChipGroup: 다중 선택 칩 그룹