ThemeController
팔레트 색상 프리뷰가 있는 테마 스와치를 그리드로 표시하는 통합 테마 선택 컴포넌트
목차
ThemeController는 선택 가능한 테마 스와치를 줄바꿈 그리드로 표시하는 통합 테마 선택 컴포넌트입니다. 각 스와치는 테마의 팔레트 색상을 원 하나로 나눠 미리보기로 보여주고 그 아래 테마 이름을 라벨로 붙이며, 선택 시 테마 이름을
onThemeChanged로 전달합니다. 크기는 size 사다리(sm / md / lg)로 고릅니다.
기본 사용법 (Basic Usage)#
ThemeController(
selectedTheme: _selected,
onThemeChanged: (name) => setState(() => _selected = name),
themes: const [
CoreThemeOption(
name: 'Ocean',
primaryColor: CoreColor.fromRGB(14, 165, 233),
secondaryColor: CoreColor.fromRGB(99, 102, 241),
),
CoreThemeOption(
name: 'Forest',
primaryColor: CoreColor.fromRGB(34, 197, 94),
secondaryColor: CoreColor.fromRGB(132, 204, 22),
),
],
)
ThemeController(
selectedTheme: _selected,
onThemeChanged: (name) => setState(() => _selected = name),
themes: const [
CoreThemeOption(
name: 'Ocean',
primaryColor: CoreColor.fromRGB(14, 165, 233),
secondaryColor: CoreColor.fromRGB(99, 102, 241),
),
CoreThemeOption(
name: 'Forest',
primaryColor: CoreColor.fromRGB(34, 197, 94),
secondaryColor: CoreColor.fromRGB(132, 204, 22),
),
],
)
빠른 오버라이드 (Chain)#
이미 만든 ThemeController 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class ThemeControllerChainExample extends StatefulWidget {
const ThemeControllerChainExample({super.key});
@override
State<ThemeControllerChainExample> createState() => _ThemeControllerChainExampleState();
}
class _ThemeControllerChainExampleState extends State<ThemeControllerChainExample> {
String _selected = 'Ocean';
@override
Widget build(BuildContext context) {
return ThemeController(
selectedTheme: _selected,
onThemeChanged: (name) => setState(() => _selected = name),
themes: const [
CoreThemeOption(
name: 'Ocean',
primaryColor: CoreColor.fromRGB(14, 165, 233),
secondaryColor: CoreColor.fromRGB(99, 102, 241),
),
CoreThemeOption(
name: 'Forest',
primaryColor: CoreColor.fromRGB(34, 197, 94),
secondaryColor: CoreColor.fromRGB(132, 204, 22),
),
CoreThemeOption(
name: 'Sunset',
primaryColor: CoreColor.fromRGB(249, 115, 22),
secondaryColor: CoreColor.fromRGB(239, 68, 68),
),
],
).withStyle(
const CoreThemeControllerStyle(
buttonSize: CoreSize.size64,
buttonRadius: CoreBorderRadius.all(CoreRadius.radius4),
selectedBorderColor: CoreColor.token(CoreColors.primary),
selectedBorderWidth: CoreStrokeWidth.stroke3,
),
);
}
}
class ThemeControllerChainExample extends StatefulComponent {
const ThemeControllerChainExample({super.key});
@override
State<ThemeControllerChainExample> createState() => _ThemeControllerChainExampleState();
}
class _ThemeControllerChainExampleState extends State<ThemeControllerChainExample> {
String _selected = 'Ocean';
@override
Component build(BuildContext context) {
return ThemeController(
selectedTheme: _selected,
onThemeChanged: (name) => setState(() => _selected = name),
themes: const [
CoreThemeOption(
name: 'Ocean',
primaryColor: CoreColor.fromRGB(14, 165, 233),
secondaryColor: CoreColor.fromRGB(99, 102, 241),
),
CoreThemeOption(
name: 'Forest',
primaryColor: CoreColor.fromRGB(34, 197, 94),
secondaryColor: CoreColor.fromRGB(132, 204, 22),
),
CoreThemeOption(
name: 'Sunset',
primaryColor: CoreColor.fromRGB(249, 115, 22),
secondaryColor: CoreColor.fromRGB(239, 68, 68),
),
],
).withStyle(
const CoreThemeControllerStyle(
buttonSize: CoreSize.size64,
buttonRadius: CoreBorderRadius.all(CoreRadius.radius4),
selectedBorderColor: CoreColor.token(CoreColors.primary),
selectedBorderWidth: CoreStrokeWidth.stroke3,
),
);
}
}
Live Preview#
코드 보기
class ThemeControllerDefaultExample extends StatefulComponent {
const ThemeControllerDefaultExample({super.key});
@override
State<ThemeControllerDefaultExample> createState() =>
_ThemeControllerDefaultExampleState();
}
class _ThemeControllerDefaultExampleState
extends State<ThemeControllerDefaultExample> {
String _selected = 'Mono';
@override
Component build(BuildContext context) {
return ThemeController(
selectedTheme: _selected,
onThemeChanged: (name) => setState(() => _selected = name),
themes: const [
CoreThemeOption(
name: 'Mono',
primaryColor: CoreColor.token(CoreColors.onSurface),
secondaryColor: CoreColor.token(CoreColors.outlineVariant),
),
CoreThemeOption(
name: 'Ocean',
primaryColor: CoreColor.fromRGB(14, 165, 233),
secondaryColor: CoreColor.fromRGB(99, 102, 241),
),
CoreThemeOption(
name: 'Forest',
primaryColor: CoreColor.fromRGB(34, 197, 94),
secondaryColor: CoreColor.fromRGB(132, 204, 22),
),
CoreThemeOption(
name: 'Sunset',
primaryColor: CoreColor.fromRGB(249, 115, 22),
secondaryColor: CoreColor.fromRGB(239, 68, 68),
),
],
);
}
}
class ThemeControllerDefaultExample extends StatefulWidget {
const ThemeControllerDefaultExample({super.key});
@override
State<ThemeControllerDefaultExample> createState() =>
_ThemeControllerDefaultExampleState();
}
class _ThemeControllerDefaultExampleState
extends State<ThemeControllerDefaultExample> {
String _selected = 'Mono';
@override
Widget build(BuildContext context) {
return ThemeController(
selectedTheme: _selected,
onThemeChanged: (name) => setState(() => _selected = name),
themes: const [
CoreThemeOption(
name: 'Mono',
primaryColor: CoreColor.token(CoreColors.onSurface),
secondaryColor: CoreColor.token(CoreColors.outlineVariant),
),
CoreThemeOption(
name: 'Ocean',
primaryColor: CoreColor.fromRGB(14, 165, 233),
secondaryColor: CoreColor.fromRGB(99, 102, 241),
),
CoreThemeOption(
name: 'Forest',
primaryColor: CoreColor.fromRGB(34, 197, 94),
secondaryColor: CoreColor.fromRGB(132, 204, 22),
),
CoreThemeOption(
name: 'Sunset',
primaryColor: CoreColor.fromRGB(249, 115, 22),
secondaryColor: CoreColor.fromRGB(239, 68, 68),
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 사용자가 여러 색상 테마 중 하나를 고르도록 할 때
- 설정 화면에서 테마 미리보기와 함께 선택지를 제공할 때
대신 다른 컴포넌트를 사용하세요:
Toggle: 라이트/다크 두 가지만 전환할 때Select: 색상 프리뷰 없이 텍스트 목록으로 충분할 때
Props / Parameters#
ThemeController#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
themes |
List<CoreThemeOption> |
필수 | 선택 가능한 테마 옵션 목록 |
selectedTheme |
String? |
null |
현재 선택된 테마 이름 |
onThemeChanged |
void Function(String) |
필수 | 테마 선택 핸들러 (옵션 이름 전달) |
size |
CoreThemeControllerSize |
md |
스와치 크기 사다리 —
sm
/
md
/
lg
가 각각 32 / 40 / 48px 버튼.
CoreThemeControllerStyle.defaultsBySize
의 항목을 고르는 시맨틱 식별자 (값 자체는
themeControllerStyle
로 오버라이드)
|
enabled |
bool |
true |
컨트롤 전체의 활성 여부 — false 면 모든 스와치가 한꺼번에 잠김 (개별 테마는 CoreThemeOption.enabled) |
themeControllerStyle |
CoreThemeControllerStyle? |
null |
크롬 / 치수 스타일 오버라이드 |
CoreThemeOption#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
name |
String |
필수 | 테마 이름 (onThemeChanged 식별자) |
primaryColor | CoreColor | 필수 | 주요 팔레트 색상 프리뷰 |
secondaryColor |
CoreColor? |
null |
보조 팔레트 색상 프리뷰 |
accentColor |
CoreColor? |
null |
액센트 팔레트 색상 프리뷰 |
neutralColor |
CoreColor? |
null |
중립 팔레트 색상 프리뷰 |
enabled |
bool |
true |
이 테마 하나를 고를 수 있는지 —
false
면 자리는 유지한 채 팔레트 색을 버리고 잠김 (컨트롤 전체는
ThemeController.enabled
)
|
크기 (Sizes)#
size 는 버튼·프리뷰·라벨을 한 벌로 묶은 사다리입니다. 버튼은 어느 단에서든 원이고(모서리가 한 변의 절반), 팔레트 원은 그 안쪽 4px 에 앉아 고리가 팔레트를 둘러싸는 모양입니다. 각 값은
themeControllerStyle 의 같은 이름 필드로 개별 오버라이드할 수 있습니다.
| 필드 | sm | md (기본) | lg |
|---|---|---|---|
buttonSize | 32px | 40px | 48px |
previewSize | 24px | 32px | 40px |
buttonRadius | 16px | 20px | 24px |
buttonSpacing | 12px | 12px | 12px |
labelGapStyle.size | 6px | 6px | 6px |
labelStyle |
labelSmall |
labelMedium |
labelLarge |
스타일 시스템 (Style System)#
ThemeController 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreThemeControllerStyle 단일 슬롯으로 흐릅니다. 시맨틱 enum (size) 과 behaviour 필드 (themes
/ selectedTheme / onThemeChanged / enabled) 는 위젯 파라미터로 직접 전달합니다.
시맨틱 vs 스타일#
-
시맨틱 enum / behaviour: 위젯 파라미터로 직접 (
size,enabled,themes,selectedTheme,onThemeChanged) -
chrome / dimensional / 슬롯 스타일:
CoreThemeControllerStyle한 곳으로 (아래 필드 표 참고) -
팔레트 색: 스타일이 아니라 데이터 —
CoreThemeOption의 네 색은 호출자가 넘기는 값이고, 스타일은 그 원의 크기·모서리(previewSize/previewBorderRadius)와 잠겼을 때의 채움(disabledPreviewColor)만 정합니다.
Resolve chain#
CoreThemeControllerStyle.default* // 사이즈 무관 default (leaf fallback)
→ CoreThemeControllerStyle.defaultsBySize[size] // 사다리 (sm / md / lg) — 체인의 base
→ CoreThemeControllerTheme.style // 프로젝트 공통
→ widget.themeControllerStyle // 인스턴스별
각 nested 슬롯 스타일 (labelStyle / labelGapStyle / clickableStyle) 은 자기 컴포넌트(Icon
/ Text / Gap / Clickable)의 자체 resolve chain 으로 다시 한 번 머지됩니다.
CoreThemeControllerStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
buttonSize |
double? |
Size of each theme button (logical px). |
previewSize |
double? |
Size of each colour-preview circle (logical px). |
buttonSpacing |
double? |
Spacing between adjacent theme buttons (logical px) — consumed as native
Wrap.spacing
/
Wrap.runSpacing
on Flutter and as the flex container
gap
CSS property on Web.
|
swatchMinWidth |
double? |
Minimum width of one swatch cell (logical px) — the cell grows past it when the theme name is wider; see [defaultSwatchMinWidth]. |
buttonRadius |
CoreBorderRadius? |
Corner radius of a theme button. |
previewBorderRadius |
CoreBorderRadius? |
Corner radius of the colour-preview circle inside a swatch button. Defaults to a fully round dot — see [defaultPreviewBorderRadius]. |
selectedBorderColor |
CoreColor? |
Border colour of the selected theme button. |
unselectedBorderColor |
CoreColor? |
Border colour of an unselected theme button. |
selectedBorderWidth |
double? |
Border thickness of the selected theme button (logical px). |
unselectedBorderWidth |
double? |
Border thickness of an unselected theme button (logical px). |
hoverBorderColor |
CoreColor? |
Ring colour of an unselected, enabled swatch while it is hovered or pressed. The selected swatch keeps [selectedBorderColor] in those states; a disabled one reacts to neither. |
hoverBorderWidth |
double? |
Ring thickness of an unselected, enabled swatch while it is hovered or pressed (logical px). |
buttonBackgroundColor |
CoreColor? |
Background of a swatch button. |
pressedPreviewShrink |
double? |
How much the palette circle's diameter shrinks while its swatch is pressed (logical px). The corner radius is [previewBorderRadius] — fully round — so the circle stays a circle at the smaller size. |
disabledBorderColor |
CoreColor? |
Border colour of a disabled swatch button. |
disabledPreviewColor |
CoreColor? |
Fill of every palette segment on a disabled swatch. |
disabledLabelColor |
CoreColor? |
Label colour on a disabled swatch. |
labelStyle |
CoreTextStyle? |
Nested [CoreTextStyle] slot for the swatch label — typography only; the colour lives in [labelColor] / [selectedLabelColor] / [disabledLabelColor]. |
labelColor |
CoreColor? |
Colour of the swatch label while the swatch is interactive and not selected. |
selectedLabelColor |
CoreColor? |
Colour of the label of the selected, interactive swatch. |
labelGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the gap between a swatch button and its label. |
clickableStyle |
CoreClickableStyle? |
Nested [CoreClickableStyle] slot for the composed per-swatch
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)#
인터랙션#
- 선택: 스와치 클릭 시
onThemeChanged(name)호출 -
선택 표시: 선택된 스와치는 팔레트를 둘러싼 강한 고리(
selectedBorderColor=onSurface, 2px)를 두르고 라벨이 같은 잉크(selectedLabelColor)로 진해집니다. 선택되지 않은 활성 스와치는 쉬는 동안 고리가 없습니다(unselectedBorderWidth= 0). -
호버 · 눌림: 선택되지 않은 활성 스와치에 옅은 고리(
hoverBorderColor=outlineVariant,hoverBorderWidth= 2px)가 생깁니다. 눌리는 동안에는 팔레트 원이 지름pressedPreviewShrink(2px)만큼 작아집니다. 선택된 스와치는 호버 · 눌림에서도 선택 고리를 지키고(눌림에서 팔레트만 줄어듭니다), 잠긴 스와치는 어느 쪽에도 반응하지 않습니다.
팔레트 프리뷰#
스와치 하나는 원 하나를 그립니다. 그 원은 옵션이 가진 색 수만큼 등분됩니다 — primaryColor 와 secondaryColor
두 색이면 반원 둘(캔버스가 그리는 그림), accentColor · neutralColor 까지 채우면 3등분 · 4등분. 색을 따로 점으로 늘어놓지 않고 한 원을 나누는 이유는, 스와치가 "토큰 몇 개의 나열" 이 아니라 "테마 하나" 로 읽혀야 하고, 색을 몇 개 넘기든 프리뷰 크기가 사다리의
previewSize 그대로여야 하기 때문입니다.
선택 표시 — 배지 없이 고리로#
선택은 배지가 아니라 팔레트를 둘러싼 고리가 말합니다. 고리는 그림자가 아니라 진짜 테두리(border)이므로, 색을 시스템 색으로 강제하는 모드(Windows 고대비 · forced-colors)에서도 시스템 테두리 색으로 남아 선택된 스와치를 다른 스와치와 구별합니다. 선택된 스와치는 호출자가 넘긴 임의 팔레트 위에 놓이므로, 고리는 브랜드 색이 아니라 강한 잉크(onSurface)입니다. 선택 상태는 색에만 의존하지 않고 접근성 속성으로도 전달됩니다 — Flutter 는
Semantics(selected:), Web 은 aria-pressed.
비활성 (두 범위)#
잠금은 두 범위로 나뉘고, 둘은 합쳐집니다 — 어느 하나만 false 여도 그 스와치는 잠깁니다.
-
컨트롤 전체 —
ThemeController.enabled: false. 읽기 전용 폼 구획처럼 그룹 전체를 한꺼번에 잠글 때. -
개별 테마 —
CoreThemeOption.enabled: false. 현재 플랜에 없는 테마처럼 한 항목만 잠글 때. 잠긴 스와치는 줄에서 자리를 지킵니다 — 존재하는 테마이긴 하므로.
잠긴 스와치는 opacity 로 흐려지지 않고 전용 토큰으로 상태를 말합니다. 선택된 잠긴 스와치의 고리는 disabledBorderColor(disabledOutline, 2px 폭 유지), 팔레트의 모든 세그먼트는 색을 버리고
disabledPreviewColor(disabledContainer), 라벨은 disabledLabelColor(disabledContent). 선택되지 않은 잠긴 스와치는 쉬는 모양 그대로 고리가 없습니다. 팔레트 색을 버리는 이유는, 색이 남아 있으면 "아직 고를 수 있다" 고 말하는 셈이기 때문입니다.
칸 폭 · 간격#
스와치 한 칸(버튼 · 간격 · 라벨을 쌓은 열)은 최소 폭 swatchMinWidth(48px) 이상입니다. 고정 폭이 아니라 바닥이라서, 라벨이 짧으면 버튼과 라벨이 48px 칸의 가운데에 서고, 테마 이름이 더 길면 그 칸만 이름 폭만큼 넓어집니다 — 이름은 말줄임 없이 끝까지 보입니다. 칸 사이 간격(buttonSpacing)은 줄 방향과 줄 사이 모두 어느 단에서나 12px 입니다.
라벨#
버튼 아래에 labelGapStyle 간격을 두고 CoreThemeOption.name 이 labelStyle 로 붙습니다. 스와치 하나의 높이는 버튼 + 간격 + 라벨 한 줄 —
md 기준 40px + 6px + labelMedium 한 줄입니다. 간격은 어느 단에서나 6px 입니다.
사용 가이드라인 (Usage Guidelines)#
이름 붙은 테마 옵션을 팔레트 스와치로 고르려면 ThemeController를 사용합니다. 일반적인 단일 선택 목록은 RadioGroup나 Select, 임의의 색 하나를 편집하려면 ColorPicker를 선택합니다. ThemeController는 옵션 이름만 통보하며 앱 테마 적용과 selectedTheme 갱신은 호출자가 담당합니다.
✅ Do#
selectedTheme 값을 themes 목록의 name 과 정확히 일치시키기
ThemeController(
selectedTheme: 'Ocean', // themes 안 CoreThemeOption.name 과 동일해야 함
onThemeChanged: (name) => setState(() => _selected = name),
themes: const [
CoreThemeOption(name: 'Ocean', primaryColor: ...),
CoreThemeOption(name: 'Forest', primaryColor: ...),
],
)
이유: 선택 표시는 selectedTheme 문자열과 각 CoreThemeOption.name 을 비교해 결정되므로, 값이 다르면 어떤 스와치에도 선택 고리가 표시되지 않습니다.
❌ Don't#
라이트/다크 2택 전환에 사용하지 않기
// ❌ 두 개짜리 선택지에 그리드 스와치 선택기 사용
ThemeController(
themes: const [
CoreThemeOption(name: 'Light', primaryColor: ...),
CoreThemeOption(name: 'Dark', primaryColor: ...),
],
selectedTheme: mode,
onThemeChanged: (name) => setMode(name),
)
이유: ThemeController는 여러 팔레트 중 하나를 고르는 그리드 UI로 설계되어 있습니다. 두 상태만 전환할 때는 Toggle이 더 적은 탭 동작과 명확한 on/off 시맨틱을 제공합니다.
접근성 (Accessibility)#
역할#
-
Flutter: 스와치 하나에 두 겹의
Semantics가 겹칩니다 — 합성된Clickable이Semantics(enabled: ..., button: true)를 붙이고,ThemeController자신이Semantics(selected: isSelected, button: true, label: option.name)를 붙입니다. 결과적으로 각 스와치는 테마 이름을 가진 버튼으로 선택 여부와 함께 안내됩니다. -
Web: 스와치마다
Clickable이<div role="button" tabindex="0">를 내고,ThemeController가 그 위에aria-label(테마 이름)과aria-pressed(선택 여부)를 얹습니다.Clickable은 caller attributes 를 마지막에 병합하므로 이 두 값이 그대로 살아남고,role="button"은 덮이지 않아 유지됩니다. -
루트에는 역할이 없습니다. Flutter 루트는 맨
Wrap이고, Web 루트<div>는configureAttributes를 override 하지 않아 호출자가 넘긴 attributes 외에는 아무role/aria-*도 붙지 않습니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Enter / Space |
포커스된 스와치 활성화 →
onThemeChanged(name)
(
Clickable
이 자기
ActivateIntent
매핑을 소유)
|
동일 (Clickable 의 keydown 이 Enter / Space 를 직접 처리) |
Tab / Shift+Tab |
스와치 사이 이동 — 스와치마다 정지점이 하나씩 | 동일 (Clickable 이 enabled 면 tabindex="0") |
↑ ↓ ← → |
컴포넌트가 처리하지 않음. 네이티브 타깃에서는 프레임워크 기본값이 방향 포커스 이동으로 받고, Flutter Web 에서는 같은 키가 스크롤로 매핑 되어 스와치 사이 이동이 되지 않습니다 | 처리하지 않음 — 아무 일도 일어나지 않습니다 |
Home / End / Escape | 없음 | 없음 |
스크린 리더#
-
이름의 출처는
CoreThemeOption.name하나입니다 — Flutter 는Semantics.label, Web 은aria-label로 같은 문자열을 씁니다. 팔레트 색 자체는 읽히지 않으므로 그 테마를 설명하는 텍스트는name이 전부입니다. -
선택 상태는 색과 모양에 의존하지 않고 플랫폼 어휘로 각각 말합니다 — Web 은
aria-pressed로 "눌린 토글 버튼", Flutter 는 selected 플래그로 "선택됨". 같은 사실을 다른 단어로 전달합니다. -
잠긴 스와치는 Flutter
Semantics(enabled: false)/ Webaria-disabled="true"로 비활성 상태를 함께 전달하고 활성화되지 않습니다. -
live region 이 없습니다. 스와치를 활성화하면 포커스가 그 스와치에 있으므로 리더가 눌림 상태 변화를 읽지만, 직전까지 선택돼 있던 스와치가 해제되는 것과 앱 테마가 실제로 바뀌었다는 사실은 어디에서도 통보되지 않습니다 —
onThemeChanged이후는 호출자 몫이고 컴포넌트에 통보 표면이 없습니다.
포커스 관리#
-
진입 — 이동 없음. 오버레이가 아니라 인라인 그리드라 마운트 시 포커스를 옮기지 않습니다 (양 플랫폼 build 어디에도 autofocus /
requestFocus호출이 없습니다). - 이탈 — 해당 없음(닫히는 표면이 아닙니다). 스와치를 활성화해도 포커스는 그 스와치에 그대로 남습니다.
-
트랩 — 없음.
Tab은 마지막 스와치를 지나 다음 요소로 그대로 빠져나갑니다. 포커스 링은 양쪽 다Clickable이 제공합니다 (FlutterFocusOutline, Web:focus-visible).
알려진 제약#
-
스와치들이 하나의 선택 그룹이라는 사실이 안내되지 않습니다. 위 "역할" 대로 루트에 역할이 없어 스와치들은 서로 무관한 토글 버튼 N 개로 읽히고, "N 개 중 1 번째" 같은 위치 안내가 나오지 않습니다. 대안은 있습니다 — Web 은 컴포넌트가 루트에 어떤 role 도 정의하지 않으므로
ThemeController(attributes: {'role': 'group', 'aria-label': '테마'})가 그대로 통과하고, Flutter 는 호출부에서Semantics(container: true, label: '테마', child: ThemeController(...))로 감싸면 됩니다. -
스와치마다 tab stop 이 하나씩 생깁니다. roving tabindex 가 적용되어 있지 않아 테마가 N 개면 그리드를 지나가는 데
Tab을 N 번 눌러야 합니다. 대안은 없습니다 — WebClickable은enabled이면 무조건tabindex="0"를 넣고,ThemeController가 스와치별 attributes 를 자기가 채워 호출자가tabindex를 고정할 진입점이 없습니다.
전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | ThemeController | ThemeController |
| 렌더 루트 | Wrap | <div> (flex flex-wrap) |
| 스와치 버튼 | Clickable > OutlinedContainer (바탕 없는 고리) |
Clickable > <div> (바탕 없는 고리) |
| 호버 · 눌림 | Clickable.onHover / onPressedChange 로 고리와 팔레트 크기를 고름 |
scoped
<style>
규칙 —
:hover
/
:active
의 고리(
!important
)와 팔레트
transform: scale()
|
| 팔레트 | ClipRRect > Row of Expanded ColoredBox |
<div> (flex, overflow-hidden) > flex-1 세그먼트 × N |
| 라벨 | Gap + Text |
Gap + <div> > Text |