ColorPicker
색상을 선택하고 편집하는 색상 선택기 컴포넌트
목차
다양한 형식으로 색상을 선택하고 편집할 수 있는 색상 선택기 컴포넌트입니다. Flutter 와 Web 모두 ColorPicker 로 통일된 API (color
/ onChanged / showAlpha / showLabel / showHexInput
/ pickerMode 등) 를 제공하며, 공통 CoreColorPickerTheme / CoreColorPickerStyle
토큰을 공유합니다. trigger 는 양쪽 모두 [swatch][hex input?] 행으로 동일한 사이즈(40px swatch · 96px hex input · 4px swatch gap) 를 사용합니다.
값 표현은 플랫폼 컨벤션을 따릅니다 — Flutter 는 Color (Color(0xFF3B82F6)), Web 은 #RRGGBB
형식의 hex 문자열. 내부 색상 연산은 양쪽 모두 hex 기반 (core_color_math.dart) 으로 통일되어 있어 정확도와 동작이 동일합니다.
기본 사용법 (Basic Usage)#
// Color 값 기반 — Flutter 컨벤션
ColorPicker(
color: Color(0xFF3B82F6),
onChanged: (color) => setState(() => _color = color),
showHexInput: true,
)
// RGB / HSL / HSV 기본 모드 + 알파 슬라이더 + 화면 색 추출
ColorPicker(
color: _color,
onChanged: handleColorChanged,
showAlpha: true,
showLabel: true,
pickerMode: CoreColorPickerMode.rgb,
enableEyeDropper: true,
)
// hex 문자열 기반 — CSS 컨벤션
ColorPicker(
color: '#3B82F6',
onChanged: (hex) => setState(() => _color = hex),
showHexInput: true,
)
ColorPicker(
color: _color,
onChanged: handleColorChanged,
showAlpha: true,
showLabel: true,
pickerMode: CoreColorPickerMode.rgb,
enableEyeDropper: true,
)
Live Preview#
코드 보기
class ColorPickerDefaultExample extends StatefulComponent {
const ColorPickerDefaultExample({super.key});
@override
State<ColorPickerDefaultExample> createState() =>
_ColorPickerDefaultExampleState();
}
class _ColorPickerDefaultExampleState
extends State<ColorPickerDefaultExample> {
String _color = '#3B82F6';
@override
Component build(BuildContext context) {
return ColorPicker(
color: _color,
showHexInput: true,
onChanged: (value) {
setState(() => _color = value);
},
);
}
}
class ColorPickerDefaultExample extends StatefulWidget {
const ColorPickerDefaultExample({super.key});
@override
State<ColorPickerDefaultExample> createState() =>
_ColorPickerDefaultExampleState();
}
class _ColorPickerDefaultExampleState
extends State<ColorPickerDefaultExample> {
Color _color = const Color(0xFF3B82F6);
@override
Widget build(BuildContext context) {
return ColorPicker(
color: _color,
showHexInput: true,
onChanged: (value) {
setState(() => _color = value);
},
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 사용자가 브랜드 색상, 테마 색상 등 임의의 색상을 직접 지정해야 하는 경우
- 디자인 도구, 에디터 등에서 색상 팔레트를 제공해야 하는 경우
- HEX 값이나 RGB / HSL / HSV 중 특정 색공간으로 값을 입력받아야 하는 경우
대신 다른 컴포넌트를 사용하세요:
- ThemeController: 임의 색 하나를 편집하지 않고 이름 붙은 테마 옵션을 선택할 때
- Select: 임의 색을 편집하지 않고 고정된 색상 목록에서 값 하나만 선택할 때
- TextField: 색상 시각 편집 없이 코드 문자열만 받는 경우. 색상 파싱·검증은 호출부가 담당
Props / Parameters#
공통 Contract 필드 (CoreColorPickerContract):
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. C 는 색의 플랫폼 표현으로 Flutter
Color, Web hex String 입니다. 이는 설명용 타입 기호이며 호출부에 별도 C 타입을 선언하는 API는 아닙니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
color | C | 필수 | 현재 선택된 색상 |
onChanged |
void Function(C)? |
null |
바깥의 유효 hex 입력 즉시 커밋 또는 패널의 Save 커밋 시 호출 |
enabled |
bool? |
null → true |
인터랙션 가능 여부 |
showAlpha |
bool? |
null → false |
알파 막대 표시. 현재 입력 동작은 없음 |
showLabel |
bool? |
null → false |
swatch 옆 hex 입력 표시 경로도 켬 |
showHexInput |
bool? |
null → false |
swatch 옆 hex 입력 표시 |
pickerMode |
CoreColorPickerMode? |
null → rgb |
RGB / HSL / HSV 색공간 |
enableEyeDropper |
bool? |
null → true |
지원 감지가 성공한 Web 환경에서 스포이드 사용 |
promptMode |
CoreColorPickerPromptMode? |
null |
공개 선언이지만 현재 렌더는 비모달 Popover 고정 (알려진 제약) |
dialogTitle |
W? |
null |
패널 상단 타이틀 |
placeholder |
W? |
null |
공개 선언. 현재 렌더에서 사용하지 않는 배선 결손 |
controller |
Object? |
null |
공개 선언. 현재 내부 PopoverController와 연결되지 않음 |
colorPickerStyle |
CoreColorPickerStyle? |
null |
인스턴스 스타일 |
스타일 시스템 (Style System)#
ColorPicker 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreColorPickerStyle 단일 슬롯으로 흐릅니다. 평면 chrome 필드 (popoverAlignment
/ popoverAnchorAlignment / popoverPadding) 는 모두 제거되었습니다.
시맨틱 vs 스타일#
-
시맨틱 enum / behaviour: 위젯/컴포넌트 파라미터로 직접 (
color,onChanged,enabled,showAlpha,showLabel,showHexInput,pickerMode,enableEyeDropper,promptMode,dialogTitle,placeholder,controller) -
chrome / dimensional / 슬롯 스타일:
CoreColorPickerStyle한 곳으로 (popoverStyle/dialogStyle/swatchButtonStyle/hexInputStyle/labelStyle/descriptionStyle/errorStyle) -
변형(variant) 교체: asChild —
dialogTitle/placeholder슬롯 등에 직접 위젯 주입
Resolve chain#
design system default for color picker
→ CoreColorPickerTheme.style // 프로젝트 공통
→ parent component slot override
→ widget.colorPickerStyle // 인스턴스별
각 nested 슬롯 스타일 (popoverStyle / dialogStyle / swatchButtonStyle
/ hexInputStyle) 은 자기 컴포넌트의 자체 resolve chain 으로 다시 한 번 머지됩니다. 예: popoverStyle.triggerStyle
의 chrome 은 → CoreButtonTheme → variant 룩업 → 부모 슬롯 → widget.colorPickerStyle.popoverStyle.triggerStyle
순.
CoreColorPickerStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
swatchSize |
double? |
Trigger swatch button width/height override (logical px). |
hexInputWidth |
double? |
Hex input field width override (logical px). |
swatchGapStyle |
CoreGapStyle? |
Horizontal gap between swatch and hex input override — nested [CoreGapStyle] slot forwarded to
Gap(gapStyle: …)
.
|
popoverWidth |
double? |
Popover panel width override (logical px). |
popoverSpacing |
double? |
Vertical spacing between popover sections override (logical px, pre-scaling). Raw primitive — flows directly into
Column.spacing
(Flutter) and the
flex flex-col
parent's CSS
gap
(Web), not a
Gap
slot. Falls back to [defaultPopoverSpacing] when null.
|
sliderHeight |
double? |
Hue / alpha slider track height override (logical px). |
modeInputSpacing |
double? |
Horizontal spacing between RGB / HSL / HSV mode input cells override (logical px, pre-scaling). Raw primitive — flows directly into
Row.spacing
(Flutter) and the
flex
parent's CSS
gap
(Web), not a
Gap
slot. Falls back to [defaultModeInputSpacing] when null.
|
modeInputLabelGapStyle |
CoreGapStyle? |
Vertical 1-off gap between a mode input cell's label and its number field override — nested [CoreGapStyle] slot forwarded to
Gap(gapStyle: …)
. The label/field stacking is a single 1-off spacer internal to the mode input cell on both platforms.
|
actionRowGapStyle |
CoreGapStyle? |
Horizontal gap between Cancel / Save buttons in the action row override — nested [CoreGapStyle] slot forwarded to
Gap(gapStyle: …)
.
|
eyeDropperIconStyle |
CoreIconStyle? |
Eye-dropper button icon style override — nested [CoreIconStyle] slot forwarded to
Icon(iconStyle: …)
. Falls back to [defaultEyeDropperIconStyle] when null.
|
recentSwatchSize |
double? |
Recent-color swatch side length override (logical px). |
recentSwatchSpacing |
double? |
Spacing (logical px) between recent-color swatches override — raw primitive, drives
Wrap.spacing/runSpacing
(Flutter) and CSS
gap
(Web), not a
Gap
slot.
|
recentColumns |
int? |
Recent-colors grid column count override. |
swatchBorderRadius |
CoreBorderRadius? |
Corner radius override for the trigger swatch button. |
sliderBorderRadius |
CoreBorderRadius? |
Corner radius override for the hue / alpha slider bars. Falls back to [defaultSliderBorderRadius] when null. |
recentSwatchBorderRadius |
CoreBorderRadius? |
Corner radius override for a recent-color swatch. Falls back to [defaultRecentSwatchBorderRadius] when null. |
saturationValueBorderRadius |
CoreBorderRadius? |
Corner radius override for the 2D saturation/value area inside the popover. |
pickMarkerSize |
double? |
Side override (logical px) of the round picked-point handle on the saturation/value area and the hue bar. Falls back to [defaultPickMarkerSize] when null. |
pickMarkerBorderColor |
CoreColor? |
Handle ring colour override. Falls back to [defaultPickMarkerBorderColor] when null. |
pickMarkerBorderWidth |
double? |
Handle ring width override (logical px). Falls back to [defaultPickMarkerBorderWidth] when null. |
pickMarkerShadow |
List<CoreShadowLayer>? |
Shadow layers under both picked-point handles. Falls back to [defaultPickMarkerShadow] when null. |
hueMarkerFillColor |
CoreColor? |
Hue-bar handle fill override. Falls back to [defaultHueMarkerFillColor] when null. |
hueMarkerBorderColor |
CoreColor? |
Hue-bar handle border colour override. Falls back to [defaultHueMarkerBorderColor] when null. |
hueMarkerBorderWidth |
double? |
Hue-bar handle border width override (logical px). Falls back to [defaultHueMarkerBorderWidth] when null. |
emptySwatchDashLength |
double? |
Dash length override (logical px) of the empty swatch's dashed outline. Falls back to [defaultEmptySwatchDashLength] when null. |
emptySwatchDashSpacing |
double? |
Dash gap override (logical px) of the empty swatch's dashed outline. Falls back to [defaultEmptySwatchDashSpacing] when null. |
borderColor |
CoreColor? |
Border colour override for the trigger swatch and the recent-colour swatches. Falls back to [defaultBorderColor] when null. |
borderWidth |
double? |
Border stroke width override (logical px) for the trigger swatch and the recent-colour swatches. Falls back to [defaultBorderWidth] when null. |
disabledBorderColor |
CoreColor? |
Border colour override for the trigger swatch when disabled. Falls back to [defaultDisabledBorderColor] (
disabledOutline
token) when null.
|
selectedRingColor |
CoreColor? |
Ring colour override for the currently-selected recent-color swatch. Falls back to [defaultSelectedRingColor] (
primary
token) when null.
|
recentSwatchBorderWidth |
double? |
Border stroke width override (logical px) for an unselected recent-color swatch. Falls back to [defaultRecentSwatchBorderWidth] when null. |
recentSwatchSelectedBorderWidth |
double? |
Border stroke width override (logical px) for the currently-selected recent-color swatch. Falls back to [defaultRecentSwatchSelectedBorderWidth] when null. |
transitionDuration |
Duration? |
Transition duration override for the swatch hover / focus colour transitions and the recent-swatch ring transition. Falls back to [defaultTransitionDuration] when null. |
popoverStyle |
CorePopoverStyle? |
Popover style for the popover-mode picker (trigger + dropdown chrome). |
dialogStyle |
CoreDialogStyle? |
Dialog style for the modal-mode picker.
Read by no resolver and no widget — wire it or remove it.
Wire it, don't default it. Both resolvers forward it (
dialogStyle: merged.dialogStyle
) into
ResolvedColorPicker.dialogStyle
, but no widget reads that field and neither platform composes a
Dialog
:
promptMode:.dialog
renders the same popover content column (Flutter
_buildPopoverContent
-equivalent SizedBox+Column, Web
_buildPopoverContent
), only with
dialogTitle
prepended. So the real defect is the missing
Dialog
composition for
promptMode:.dialog
(style-contract.md facet 4: modal centre surface must be
Dialog
, not a re-drawn panel) — fixing that makes the slot live and gives it a natural partial default. Until then it must NOT get a default: a value on a slot nothing reads is an orphan constant, the mirror violation. Deletion would also be viable but is a breaking public-field removal and not this PR's call. NOTE:
CoreColorPickerStyle.defaultHexInputStyle
's doc comment already asserts 'no platform composes a Dialog' — that claim is correct and is corroborated here.
|
swatchButtonStyle |
CoreButtonStyle? |
Style applied to each preset / history color swatch button. Carries no
defaultSwatchButtonStyle
. No platform composes a
Button
for the swatch — both read exactly two sub-fields off this slot as the
first rung of a precedence chain
over the flat swatch geometry:
merged.swatchButtonStyle?.width ?? merged.swatchSize ?? defaultSwatchSize
and
merged.swatchButtonStyle?.borderRadius ?? merged.swatchBorderRadius ?? defaultSwatchBorderRadius
. A default setting either sub-field would outrank [defaultSwatchSize] / [defaultSwatchBorderRadius] and make those two named defaults — the values actually painted — unreachable.
|
recentSwatchClickableStyle |
CoreClickableStyle? |
Nested style for the composed recent-color swatch
Clickable
— carries any press / focus-ring / cursor chrome overrides for the swatch affordance. Merged on top of [defaultRecentSwatchClickableStyle] and raw-forwarded to
Clickable(clickableStyle:)
.
|
hexInputStyle |
CoreTextFieldStyle? |
Style applied to the hex input field (showHexInput: true). |
modeTabsStyle |
CoreToggleGroupStyle? |
Mode tabs override — nested [CoreToggleGroupStyle] slot raw-forwarded to the composed
ToggleGroup(toggleGroupStyle: …)
.
|
modeInputLabelStyle |
CoreTextStyle? |
Channel caption (
R
/
G
/
B
) text style override. Falls back to [defaultModeInputLabelStyle] when null.
|
labelStyle |
CoreTextStyle? |
Label text style (text shown above the trigger).
Read by no resolver and no widget — wire it or remove it.
Wire it or delete it — do not default it. Forwarded into
ResolvedColorPicker.labelStyle
on both platforms and read by nobody:
ColorPicker
has no
label
slot at all (its
showLabel
bool only gates the hex
TextField
's visibility alongside
showHexInput
, and the internal mode-input captions use a
Text(...).bodySmall.onSurfaceVariant
chain, not this slot). Cleanest resolution is to let ColorPicker compose
FormField
, which already owns label/description/error and their defaults, and drop these three slots; otherwise add real
label
widget slots on both platforms. A default on an unread slot would just be an orphan constant.
|
descriptionStyle |
CoreTextStyle? |
Description / helper text style.
Read by no resolver and no widget — wire it or remove it.
Same as
labelStyle
: forwarded into
ResolvedColorPicker.descriptionStyle
on both platforms, read by nobody, and
ColorPicker
renders no description/helper text node on either platform. Route through a composed
FormField
(which owns
defaultDescriptionTextStyle
) or remove in a breaking release; do not give it a value while nothing reads it.
|
errorStyle |
CoreTextStyle? |
Error text style.
Read by no resolver and no widget — wire it or remove it.
Same as
labelStyle
: forwarded into
ResolvedColorPicker.errorStyle
on both platforms, read by nobody, and
ColorPicker
renders no error text node on either platform. Per widget/failure-surfaces.md the error surface belongs to the composed
FormField
/
ValidatorHint
, so route it there or remove in a breaking release; do not give it a value while nothing reads it.
|
Migration — 옛 평면 필드 → 새 위치 매핑#
| 옛 chrome 필드 | 새 위치 |
|---|---|
popoverAlignment |
popoverStyle.placement (또는 Popover 의 placement 직접 지정) |
popoverAnchorAlignment | popoverStyle.placement |
popoverPadding | popoverStyle.panelPadding |
사용 예 (Flutter)#
ColorPicker(
color: const Color(0xFF3B82F6),
showHexInput: true,
onChanged: handleColorChanged,
colorPickerStyle: CoreColorPickerStyle(
popoverStyle: CorePopoverStyle(
panelStyle: CorePopupStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
padding: CoreEdgeInsets.all(CoreSpace.space16),
),
gap: CoreSpace.space8,
),
swatchButtonStyle: CoreButtonStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius4),
),
hexInputStyle: CoreTextFieldStyle(
borderColor: CoreColor.token(CoreColors.primary),
),
labelStyle: CoreTextStyle(fontWeight: 600),
),
)
사용 예 (Web)#
ColorPicker(
color: '#3B82F6',
showHexInput: true,
onChanged: handleColorChanged,
colorPickerStyle: CoreColorPickerStyle(
popoverStyle: CorePopoverStyle(
panelStyle: CorePopupStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
padding: CoreEdgeInsets.all(CoreSpace.space16),
),
),
hexInputStyle: CoreTextFieldStyle(
borderColor: CoreColor.token(CoreColors.primary),
),
),
)
빠른 오버라이드 (Chain)#
이미 만든 ColorPicker 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class ColorPickerChainExample extends StatefulWidget {
const ColorPickerChainExample({super.key});
@override
State<ColorPickerChainExample> createState() => _ColorPickerChainExampleState();
}
class _ColorPickerChainExampleState extends State<ColorPickerChainExample> {
Color _color = const Color(0xFF3B82F6);
@override
Widget build(BuildContext context) {
return ColorPicker(
color: _color,
showHexInput: true,
onChanged: (value) {
setState(() => _color = value);
},
).withStyle(
const CoreColorPickerStyle(
swatchSize: CoreSpace.space48,
hexInputWidth: CoreSpace.space112,
swatchGapStyle: CoreGapStyle(size: CoreSpace.space12),
swatchBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
borderColor: CoreColor.token(CoreColors.primary),
borderWidth: CoreStrokeWidth.stroke2,
),
);
}
}
class ColorPickerChainExample extends StatefulComponent {
const ColorPickerChainExample({super.key});
@override
State<ColorPickerChainExample> createState() => _ColorPickerChainExampleState();
}
class _ColorPickerChainExampleState extends State<ColorPickerChainExample> {
String _color = '#3B82F6';
@override
Component build(BuildContext context) {
return ColorPicker(
color: _color,
showHexInput: true,
onChanged: (value) {
setState(() => _color = value);
},
).withStyle(
const CoreColorPickerStyle(
swatchSize: CoreSpace.space48,
hexInputWidth: CoreSpace.space112,
swatchGapStyle: CoreGapStyle(size: CoreSpace.space12),
swatchBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
borderColor: CoreColor.token(CoreColors.primary),
borderWidth: CoreStrokeWidth.stroke2,
),
);
}
}
Theme 오버라이드#
프로젝트 chrome은 CoreComponentTheme.colorPicker의 CoreColorPickerStyle로 지정합니다. showAlpha / showLabel / showHexInput / pickerMode / enableEyeDropper 같은 동작 값은 위젯에서 지정하며, 선언만 있고 동작이 빠진 필드는 아래 알려진 제약을 확인하세요.
CoreComponentTheme(
colorPicker: CoreColorPickerTheme(
style: CoreColorPickerStyle(
popoverStyle: CorePopoverStyle(
panelStyle: CorePopupStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
padding: CoreEdgeInsets.all(CoreSpace.space16),
),
),
),
),
)
// behaviour 값은 위젯 파라미터로:
ColorPicker(
color: color,
onChanged: handleChange,
showAlpha: true,
showHexInput: true,
)
변형 (Variants)#
swatch + hex 텍스트 입력#
ColorPicker(
color: const Color(0xFF3B82F6),
showHexInput: true,
onChanged: handleChanged,
)
EyeDropper#
ColorPicker(
color: _color,
enableEyeDropper: true,
onChanged: handleChanged,
)
enableEyeDropper: true + 호스트가 window.EyeDropper API 를 지원하면 액션 행에 스포이드 버튼 표시. Chromium 95+ / Flutter Web target 에서 동작. 미지원 환경 (Firefox · Safari · 네이티브 Flutter) 에서는 버튼이 자동 숨김.
최근 색 (Recent Colors)#
ColorPicker 는 같은 화면 안에서 commit 한 색상을 RecentColorsScope 를 통해 공유합니다. picker 호스팅 화면을 scope 로 wrap 하면 그 안의 모든 picker 가 동일 history 를 보고, Save 액션이 새 색을 push 하면 다른 picker 의 그리드에 즉시 반영됩니다.
// Flutter
RecentColorsScope(
initialRecentColors: [
CoreColor.fromHex('#3B82F6'),
CoreColor.fromHex('#E11D48'),
],
maxRecentColors: 10,
onRecentColorsChanged: (colors) => persist(colors),
child: ColorPaletteEditor(...),
)
// Web — 동일 시그니처
RecentColorsScope(
initialRecentColors: [
CoreColor.fromHex('#3B82F6'),
CoreColor.fromHex('#E11D48'),
],
maxRecentColors: 10,
onRecentColorsChanged: (colors) => persist(colors),
child: ColorPaletteEditor(...),
)
scope 바깥에서는 history 그리드가 숨겨지고 커밋 콜백만 동작합니다. 최근 색을 공유해야 하는 picker들이 같은 scope를 상속하도록 경계를 고릅니다. 화면별로 분리할 수도 있고 앱 전체의 같은 history를 공유할 수도 있습니다.
그리드 토큰#
그리드 swatch 사이즈와 컬럼 수는 CoreColorPickerStyle 의 defaultRecentSwatchSize / defaultRecentSwatchGap
/ defaultRecentColumns 에서 결정됩니다 (기본 20px swatch · 모서리 6 · 4px gap · 10 columns). 최근 색은
한 줄로만 그리며 컬럼 수를 넘는 색은 보이지 않습니다. 양 플랫폼 grid 가 같은 토큰 source 를 참조합니다.
동작 스펙 (Behavior)#
인터랙션#
- swatch 클릭: 통일된
Popover팝오버 열림 (양 플랫폼 동일 인프라) -
바깥 hex 입력: 유효한 hex 문자열을 정규화한 즉시
_commit→onChanged호출. Save를 기다리지 않음 -
mode tab (RGB / HSL / HSV): 공용 묶음 토글
ToggleGroup을 합성 — 트랙과 올라간 선택 면, hover / focus / disabled 는ToggleGroup이 처리. 칠은modeTabsStyle로 넘김.size: sm(칸 26 · 트랙 32,CoreColorPickerContract.defaultModeTabsSize)에fillWidth: true로 켜서 트랙이 판 폭을 채우고 세 모드가 3등분하므로, 아래 폭 가득한 색 영역 · 입력칸과 가장자리가 맞음 -
SV (Saturation / Value) 영역: 모서리 8, 테두리 없음. 마우스 드래그로 saturation / value 동시 조정 (cursor: precise / crosshair). 고른 지점에 지름 16 의 둥근 핸들(
pickMarkerSize)이 놓임 — 고른 색 채움 + 바깥쪽 항상 흰색 링 2(pickMarkerBorderColor; 다크에서surfaceRaised는 어두운 회색이라 쓰지 않음) + 가장 약한 그림자(pickMarkerShadow) -
hue 슬라이더: 높이 8, 모서리 4, 테두리 없는 막대 위에 지름 16 핸들이 얹힘 (조작 영역은 핸들 높이 16). 핸들은 흰 원(
hueMarkerFillColor) + 검정 12% 테두리 1(hueMarkerBorderColor·hueMarkerBorderWidth) + 가장 약한 그림자. 좌우 드래그로 hue 조정 (cursor: ew-resize). 알파 막대도 같은 막대 모서리 · 무테두리 - 취소 · 스포이드 버튼:
ghost변형. 저장만primary - 채널 입력 (R / G / B):
TextField합성. 라벨은onSurfaceSubtle - EyeDropper: 지원 환경에서 화면 픽셀 샘플링
- 취소·외부 닫힘: Save 없이 패널이 닫히면 draft가 마지막 커밋 색으로 롤백.
Escape닫힘은 현재 Web에만 있음
색 없음 (Empty swatch)#
값이 비어 있으면 트리거는 채움 없는 점선 빈 스와치(CoreDashedBorder 의 3 / 2 점선, 테두리 색은 borderColor)로 그려집니다. Flutter 에서는 완전히 투명한
Color(alpha 0), Web 에서는 유효하지 않은 hex(빈 문자열 등)가 "색 없음"입니다. 패널에서 색을 고르면 채워지고, 취소하면 다시 빈 스와치로 돌아갑니다. 패널은
#000000 에서 시작합니다.
비활성일 때도 빈 스와치는 그대로 점선이며, 점선 색만 비활성 테두리 색(disabledBorderColor)으로 바뀝니다. "색 없음"은 별도 파라미터가 아니라 위 값 표현(투명
Color / 유효하지 않은 hex)이며, 이를 위한 공개 API 는 더하지 않습니다.
상태 전환#
default→picking: swatch 클릭 시 (Popover open)-
picking→committed: Save 버튼 클릭.onChanged호출 +RecentColorsScope에addHistory -
picking→cancelled: Cancel 또는 외부 닫힘. swatch / hex input 이 마지막 committed 색으로 롤백 disabled: 트리거 row 비활성, 팝오버 mount 안 됨 (aria-disabled="true")
색 commit 흐름#
swatch click → popover open → live preview drag/type → Save → _commit(hex)
↓
widget.onChanged(color/hex)
↓
storage.addHistory(CoreColor)
패널 안 드래그·채널 입력·최근 색·EyeDropper는 draft 미리보기만 갱신하고 Save에서 커밋합니다. 바깥 hex 입력은 별도 즉시 커밋 경로이며 같은
onChanged 를 사용합니다. 최근 색 저장도 Flutter는 _commit 안에, Web은 Save 경로에 있어 바깥 hex 입력의 history 반영은 현재 다릅니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
함께 최근 색을 공유할 picker를 같은 RecentColorsScope 로 감싸기
RecentColorsScope(
initialRecentColors: [
CoreColor.fromHex('#3B82F6'),
CoreColor.fromHex('#E11D48'),
],
maxRecentColors: 10,
onRecentColorsChanged: (colors) => persist(colors),
child: ColorPaletteEditor(...),
)
앱 전체 공유가 필요하면 루트 scope, 화면별 history가 필요하면 더 가까운 화면 scope를 씁니다. 가장 가까운 scope가 실제 공유 경계를 정합니다.
❌ Don't#
onChanged를 실시간 드래그 미리보기 트리거로 쓰지 않기
// ❌ SV 영역 드래그·hue 슬라이더 조작마다 호출될 거라 기대
ColorPicker(
color: color,
onChanged: (c) => applyLivePreview(c),
)
패널 안의 드래그·타이핑은 Save 전까지 onChanged 를 호출하지 않습니다. 반면 바깥 hex 입력은 유효해지는 즉시 호출하므로 모든 콜백을 Save 버튼 클릭으로 해석하지 마세요.
접근성 (Accessibility)#
역할#
| 요소 | Flutter | Web |
|---|---|---|
| 루트 | 그룹 역할 없음 |
role="group"
. 단
enabled: false
인 경로에서는 role 도
aria-disabled
도 붙지 않습니다
|
| 트리거 행 | 없음 | role="group" + 비활성 시 aria-disabled="true" (루트가 아니라 이 행에 붙습니다) |
| 트리거 스와치 | 역할 없음 — 포커스를 받지 않는 Container |
<button type="button">
+
aria-label="Select color"
+ 비활성 시
disabled
|
| 팝오버 패널 | 역할 없음 (Popover 가 트리거에 Semantics(expanded: …) 만 얹습니다) |
Popover
bubble 에
role="dialog"
, 콘텐츠에
aria-label="Color picker"
|
| 모드 탭 (RGB / HSL / HSV) | Button 세 개 — 선택 상태 시맨틱이 없습니다 |
role="tablist" 컨테이너 + 각 버튼에 role="tab" + aria-selected |
| 채도·명도 영역 | 역할 없음 | 역할 없는 <div> + aria-label="Saturation and value" |
| 색조(hue) 슬라이더 | 역할 없음 |
role="slider"
+
aria-label="Hue"
—
aria-valuenow
/
valuemin
/
valuemax
는 없습니다
|
| 알파 슬라이더 | 역할 없음 | role="slider" + aria-label="Alpha" — 값 속성도 이벤트도 없습니다 |
| 채널 입력 (R/G/B 등) | 이름 없는 TextField — 라벨은 형제 Text |
이름 없는 <input> — 라벨은 형제 <div> |
| 최근 색 스와치 | Clickable — 이름 없음 |
Clickable + aria-label(해당 색의 hex) |
| 스포이드 / Cancel / Save | Button — 스포이드는 로케일 colorPickerEyeDropper 의 시맨틱 이름 |
동일 — 스포이드는 같은 로케일 키의 aria-label |
키보드#
| 키 | Flutter | Web |
|---|---|---|
Tab |
스와치는 정지점이 아닙니다. showHexInput / showLabel 을 켜야 트리거 행에 정지점(hex 입력)이 생깁니다 |
스와치 <button> → (hex 입력) → 패널이 열리면 모드 탭 → 채널 입력 → 최근 색 → 스포이드 → Cancel → Save |
Enter / Space |
트리거에 포커스가 갈 수 없어 팝오버를 열 수 없습니다 | 스와치에서 팝오버를 엽니다 (<button> 의 click 이 Popover wrapper 로 버블링) |
Escape |
팝오버가 닫히지 않습니다 | 팝오버를 닫습니다 (document keydown 리스너) |
| 문자 / 숫자 입력 | hex 입력과 채널 입력(R/G/B 등)으로 값을 지정합니다 | 동일 |
모드 탭의 ← / →, Home / End |
전용 탐색 없음 | 탭 사이 포커스 이동 (끝에서 순환), Enter / Space 로 모드 선택 |
← → ↑ ↓ |
채도·명도 영역과 색조·알파 슬라이더에서 동작하지 않습니다 (키 핸들러 없음) | 동일 — 게다가 세 표면 모두 tabindex 가 없어 포커스 자체를 받지 못합니다 |
스크린 리더#
-
트리거 이름은 Web 에만 있습니다.
colorPickerSelectLabel(Select color)이 스와치<button>의aria-label로 붙습니다. Flutter 스와치는Container라 이름도 역할도 없습니다. -
패널 이름도 Web 에만 있습니다.
colorPickerLabel(Color picker)이 팝오버 콘텐츠의aria-label로 붙습니다. Flutter 에는 대응Semantics가 없습니다. -
현재 색 값이 어느 플랫폼에서도 통보되지 않습니다. 색은 스와치 배경과 그라디언트로만 표현되고,
role="slider"를 단 색조·알파 슬라이더조차aria-valuenow를 내보내지 않습니다. 값을 읽으려면 hex 입력이나 채널 입력에 포커스해 그 값을 읽는 경로뿐입니다. -
채널 입력에는 이름이 없습니다(양 플랫폼).
R/G/B라벨은 입력과 연결되지 않은 형제 텍스트라 리더는 이름 없는 숫자 입력 세 개로 읽습니다. -
모드 탭 선택 상태는 Web 에만 통보됩니다. Web 은
aria-selected로 전달하지만 Flutter 는 버튼 variant(primary↔ghost)의 색으로만 구분합니다. -
최근 색 선택 상태는 어느 쪽도 통보하지 않습니다. 시각적으로는 테두리 색과 두께로 구분하지만
aria-selected/Semantics(selected:)가 없습니다. - 라이브 리전은 없습니다.
포커스 관리#
- 진입 — 팝오버가 열려도 포커스를 패널로 옮기지 않습니다(양 플랫폼). 패널 안 어떤 요소에도 autofocus 가 없습니다.
- 이탈 — 닫힐 때 트리거로 포커스를 되돌리지 않습니다(양 플랫폼).
-
트랩 — 없습니다.
Popover를 기본값(modal: false)으로 합성하므로 Web 배경은inert가 되지 않고, 패널은 오버레이 레이어로 포탈되어Tab이 문서 순서를 계속 걸어갑니다.promptMode: .dialog로 모달 경로를 고를 수도 없습니다 — 아래 제약을 참고하십시오.
알려진 제약#
-
Flutter 에서는 키보드로 색상 선택기를 열 수 없습니다. 트리거 스와치가 포커스를 받지 않는
Container이고,Popover가 그것을Listener기반TriggerTapDetector로 감싸기 때문에 탭으로만 열립니다. Web 은 같은 자리를<button type="button">+aria-label로 그립니다. Flutter 에서는showHexInput을 켜서 hex 입력 필드로 값을 직접 입력하는 경로가 대안입니다. -
채도/명도 영역과 색상(hue) 슬라이더는 포인터 전용입니다. 양 플랫폼 모두 키 핸들러가 없고, Web 은
tabindex조차 없어 포커스를 받지 못하며 hue 는role="slider"를 달고도aria-valuenow를 내보내지 않습니다. Web 은mousedown/mousemove만 등록해 터치 드래그로는 조작되지 않습니다(Flutter 는onPanDown/onPanUpdate라 터치가 동작합니다). 대안은 hex 입력 필드나 RGB / HSL / HSV 숫자 입력으로 값을 지정하는 것입니다. -
알파 슬라이더는 표시 전용입니다.
showAlpha: true로 켜도 양 플랫폼 모두 그 막대에 입력 핸들러가 하나도 없어 알파 값을 바꿀 수 없고, 현재 색의 투명도 그라디언트를 보여주기만 합니다. 대안은 없습니다. - Flutter 의 패널·모드 탭·슬라이더 배선은 Web 보다 적습니다. Web 의 패널 이름, 모드 탭 선택 상태, 슬라이더 역할·이름에 대응하는 배선이 Flutter 에는 없습니다. 이름이 보완된 스포이드 버튼과는 별개입니다.
-
aria-disabled는 루트에 한 번도 붙지 않습니다. 비활성 표시는 트리거 행<div>에만 붙고, 비활성 경로의 루트에는role="group"조차 없습니다. Flutter 는 비활성 상태를 스와치 불투명도로만 표현합니다 — 투명도는 OS 의 색 강제 모드에서 보정되지 않으므로 비활성이 활성처럼 읽힐 수 있습니다. -
promptMode는 아무 효과가 없습니다.CoreColorPickerPromptMode.dialog를 넘겨도 양 플랫폼 모두 이 필드를 선언·copyWith만 하고 build 어디에서도 읽지 않아 항상 비모달 팝오버로 렌더됩니다. 즉DatePicker/TimePicker가 제공하는 "모달 다이얼로그로 전환해 포커스를 가둔다" 는 탈출구가ColorPicker에는 없습니다.
브라우저 검증 — 이 컴포넌트의 Web 키보드 이동은 야간 파리티 스윕의 키보드 프로브(
tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제로 키를 눌러, 포커스가 기대한 항목으로 옮겨 갔는지 확인합니다. 핸들러가 달렸는지가 아니라 포커스가 어디에 있는지를 봅니다.
크로스 플랫폼 차이점 (Platform Differences)#
색·콘텐츠 표현과 입력 장치의 실제 지원 범위를 구분해야 합니다. promptMode 등의 배선 결손은 의도된 플랫폼 옵션이 아닙니다.
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | ColorPicker | ColorPicker |
| 색상 타입 | Color | String (hex) |
| 색 없음 | alpha 0 인 Color | 유효하지 않은 hex |
| Style 제네릭 | CoreColorPickerStyle |
CoreColorPickerStyle |
| 색공간 선택 | CoreColorPickerMode | CoreColorPickerMode |
| 팝오버 인프라 | Popover (overlay-driven) |
Popover (OverlayHost portal) |
| EyeDropper | Web target 에서 window.EyeDropper API 호출 | 동일 |
| 외부 클릭 / ESC 닫기 | 외부 탭. 기본 non-modal 팝오버의 Escape는 없음 | 외부 클릭 · Escape |
| RecentColorsScope | Data<CoreColorHistoryStorage> InheritedWidget |
_RecentColorsInherited InheritedComponent |