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

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#

사용 시기 (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는 아닙니다.

속성타입기본값설명
colorC필수현재 선택된 색상
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 직접 지정)
popoverAnchorAlignmentpopoverStyle.placement
popoverPaddingpopoverStyle.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)#

역할#

요소FlutterWeb
루트 그룹 역할 없음 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

키보드#

키FlutterWeb
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 등의 배선 결손은 의도된 플랫폼 옵션이 아닙니다.

항목FlutterWeb
클래스명ColorPickerColorPicker
색상 타입ColorString (hex)
색 없음alpha 0 인 Color유효하지 않은 hex
Style 제네릭 CoreColorPickerStyle CoreColorPickerStyle
색공간 선택CoreColorPickerModeCoreColorPickerMode
팝오버 인프라 Popover (overlay-driven) Popover (OverlayHost portal)
EyeDropperWeb target 에서 window.EyeDropper API 호출동일
외부 클릭 / ESC 닫기외부 탭. 기본 non-modal 팝오버의 Escape는 없음외부 클릭 · Escape
RecentColorsScope Data<CoreColorHistoryStorage> InheritedWidget _RecentColorsInherited InheritedComponent
  • TextField: 색상 코드를 직접 텍스트로 입력
  • Slider: 개별 채널 값을 조정
  • Popover: ColorPicker 가 사용하는 팝오버 인프라