sh-ui-cli 0.15.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/bin/sh-ui.mjs +6 -0
  2. package/data/changelog/versions.json +354 -0
  3. package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
  4. package/data/registry/flutter/registry.json +336 -0
  5. package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
  6. package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
  7. package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
  8. package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
  9. package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
  10. package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
  11. package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
  12. package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
  13. package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
  14. package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
  15. package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
  16. package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
  17. package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
  18. package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
  19. package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
  20. package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
  21. package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
  22. package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
  23. package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
  24. package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
  25. package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
  26. package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
  27. package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
  28. package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
  29. package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
  30. package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
  31. package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
  32. package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
  33. package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
  34. package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
  35. package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
  36. package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
  37. package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
  38. package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
  39. package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
  40. package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
  41. package/data/registry/react/components/accordion/index.tsx +85 -0
  42. package/data/registry/react/components/accordion/styles.css +94 -0
  43. package/data/registry/react/components/animations/animations.css +51 -0
  44. package/data/registry/react/components/avatar/index.tsx +75 -0
  45. package/data/registry/react/components/avatar/styles.css +36 -0
  46. package/data/registry/react/components/badge/index.tsx +42 -0
  47. package/data/registry/react/components/badge/styles.css +57 -0
  48. package/data/registry/react/components/base/base.css +102 -0
  49. package/data/registry/react/components/breadcrumb/index.tsx +154 -0
  50. package/data/registry/react/components/breadcrumb/styles.css +82 -0
  51. package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
  52. package/data/registry/react/components/button/index.tsx +47 -0
  53. package/data/registry/react/components/button/styles.css +93 -0
  54. package/data/registry/react/components/card/index.tsx +86 -0
  55. package/data/registry/react/components/card/styles.css +73 -0
  56. package/data/registry/react/components/carousel/index.tsx +432 -0
  57. package/data/registry/react/components/carousel/styles.css +155 -0
  58. package/data/registry/react/components/checkbox/index.tsx +98 -0
  59. package/data/registry/react/components/checkbox/styles.css +75 -0
  60. package/data/registry/react/components/code-panel/copy.tsx +56 -0
  61. package/data/registry/react/components/code-panel/index.tsx +193 -0
  62. package/data/registry/react/components/code-panel/styles.css +124 -0
  63. package/data/registry/react/components/color-picker/index.tsx +466 -0
  64. package/data/registry/react/components/color-picker/styles.css +166 -0
  65. package/data/registry/react/components/combobox/index.tsx +167 -0
  66. package/data/registry/react/components/combobox/styles.css +151 -0
  67. package/data/registry/react/components/context-menu/index.tsx +253 -0
  68. package/data/registry/react/components/context-menu/styles.css +140 -0
  69. package/data/registry/react/components/date-picker/index.tsx +757 -0
  70. package/data/registry/react/components/date-picker/styles.css +279 -0
  71. package/data/registry/react/components/dialog/index.tsx +97 -0
  72. package/data/registry/react/components/dialog/styles.css +127 -0
  73. package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
  74. package/data/registry/react/components/dropdown-menu/styles.css +150 -0
  75. package/data/registry/react/components/file-upload/index.tsx +489 -0
  76. package/data/registry/react/components/file-upload/styles.css +170 -0
  77. package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
  78. package/data/registry/react/components/form/context.ts +92 -0
  79. package/data/registry/react/components/form/field.test.tsx +230 -0
  80. package/data/registry/react/components/form/field.tsx +236 -0
  81. package/data/registry/react/components/form/focus-first-error.ts +54 -0
  82. package/data/registry/react/components/form/form.section.test.tsx +58 -0
  83. package/data/registry/react/components/form/form.test.tsx +146 -0
  84. package/data/registry/react/components/form/form.tsx +180 -0
  85. package/data/registry/react/components/form/index.tsx +61 -0
  86. package/data/registry/react/components/form/steps.test.tsx +106 -0
  87. package/data/registry/react/components/form/steps.tsx +193 -0
  88. package/data/registry/react/components/form/store.test.ts +206 -0
  89. package/data/registry/react/components/form/store.ts +318 -0
  90. package/data/registry/react/components/form/styles.css +47 -0
  91. package/data/registry/react/components/form/types.ts +104 -0
  92. package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
  93. package/data/registry/react/components/form/utils.test.ts +44 -0
  94. package/data/registry/react/components/form/utils.ts +49 -0
  95. package/data/registry/react/components/form/validation.test.ts +67 -0
  96. package/data/registry/react/components/form/validation.ts +64 -0
  97. package/data/registry/react/components/form-rhf/README.md +27 -0
  98. package/data/registry/react/components/form-rhf/index.tsx +289 -0
  99. package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
  100. package/data/registry/react/components/form-tanstack/README.md +27 -0
  101. package/data/registry/react/components/form-tanstack/index.tsx +352 -0
  102. package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
  103. package/data/registry/react/components/form-yup/README.md +22 -0
  104. package/data/registry/react/components/form-yup/index.tsx +50 -0
  105. package/data/registry/react/components/form-yup/yup.test.ts +27 -0
  106. package/data/registry/react/components/header/index.tsx +257 -0
  107. package/data/registry/react/components/header/styles.css +190 -0
  108. package/data/registry/react/components/input/index.tsx +517 -0
  109. package/data/registry/react/components/input/styles.css +203 -0
  110. package/data/registry/react/components/label/index.tsx +54 -0
  111. package/data/registry/react/components/label/styles.css +90 -0
  112. package/data/registry/react/components/menubar/index.tsx +34 -0
  113. package/data/registry/react/components/menubar/styles.css +45 -0
  114. package/data/registry/react/components/pagination/index.tsx +271 -0
  115. package/data/registry/react/components/pagination/styles.css +105 -0
  116. package/data/registry/react/components/popover/index.tsx +115 -0
  117. package/data/registry/react/components/popover/styles.css +65 -0
  118. package/data/registry/react/components/progress/index.tsx +56 -0
  119. package/data/registry/react/components/progress/styles.css +41 -0
  120. package/data/registry/react/components/radio/index.tsx +67 -0
  121. package/data/registry/react/components/radio/styles.css +80 -0
  122. package/data/registry/react/components/select/index.tsx +236 -0
  123. package/data/registry/react/components/select/styles.css +193 -0
  124. package/data/registry/react/components/separator/index.tsx +48 -0
  125. package/data/registry/react/components/separator/styles.css +15 -0
  126. package/data/registry/react/components/sidebar/index.tsx +1084 -0
  127. package/data/registry/react/components/sidebar/styles.css +502 -0
  128. package/data/registry/react/components/skeleton/index.tsx +24 -0
  129. package/data/registry/react/components/skeleton/styles.css +24 -0
  130. package/data/registry/react/components/slider/index.tsx +300 -0
  131. package/data/registry/react/components/slider/styles.css +64 -0
  132. package/data/registry/react/components/spinner/index.tsx +40 -0
  133. package/data/registry/react/components/spinner/styles.css +37 -0
  134. package/data/registry/react/components/switch/index.tsx +41 -0
  135. package/data/registry/react/components/switch/styles.css +83 -0
  136. package/data/registry/react/components/tabs/index.tsx +93 -0
  137. package/data/registry/react/components/tabs/styles.css +148 -0
  138. package/data/registry/react/components/textarea/index.tsx +25 -0
  139. package/data/registry/react/components/textarea/styles.css +54 -0
  140. package/data/registry/react/components/theme/index.tsx +91 -0
  141. package/data/registry/react/components/toast/index.tsx +257 -0
  142. package/data/registry/react/components/toast/styles.css +290 -0
  143. package/data/registry/react/components/toggle/index.tsx +133 -0
  144. package/data/registry/react/components/toggle/styles.css +85 -0
  145. package/data/registry/react/components/tooltip/index.tsx +85 -0
  146. package/data/registry/react/components/tooltip/styles.css +44 -0
  147. package/data/registry/react/components/z-index/z-index.css +16 -0
  148. package/data/registry/react/hooks/use-active-section.ts +104 -0
  149. package/data/registry/react/hooks/use-media-query.ts +27 -0
  150. package/data/registry/react/lib/cn.ts +39 -0
  151. package/data/registry/react/registry.json +835 -0
  152. package/data/summaries/flutter.json +42 -0
  153. package/data/summaries/react.json +50 -0
  154. package/data/tokens/build.mjs +553 -0
  155. package/data/tokens/src/primitives.json +146 -0
  156. package/data/tokens/src/semantic.json +146 -0
  157. package/package.json +9 -2
  158. package/src/add.mjs +13 -12
  159. package/src/list.mjs +3 -11
  160. package/src/mcp.mjs +308 -0
  161. package/src/paths.mjs +52 -0
  162. package/src/remove.mjs +4 -11
@@ -0,0 +1,466 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import "./styles.css";
5
+
6
+ /* ───────────── types ───────────── */
7
+
8
+ interface HSV {
9
+ h: number; // 0~360
10
+ s: number; // 0~1
11
+ v: number; // 0~1
12
+ }
13
+
14
+ interface HSVA extends HSV {
15
+ a: number; // 0~1
16
+ }
17
+
18
+ export interface ColorPickerProps
19
+ extends Omit<
20
+ React.HTMLAttributes<HTMLDivElement>,
21
+ "onChange" | "defaultValue" | "children"
22
+ > {
23
+ /** 제어 모드 색상값 (hex, 예: `"#FF8800"`). 6자리 / 3자리 / `#` 생략 모두 허용. */
24
+ value?: string;
25
+ /** 색상 변경 콜백. 항상 6자리 대문자 hex(`"#RRGGBB"`)로 통일되어 전달된다. */
26
+ onChange?: (hex: string) => void;
27
+ /**
28
+ * 비제어 모드 초기값.
29
+ * @default "#000000"
30
+ */
31
+ defaultValue?: string;
32
+ /**
33
+ * compound 모드. 미지정 시 기본 레이아웃(Saturation + Hue + Hex)이 자동 렌더된다.
34
+ * 직접 조립하려면 `ColorPickerSaturation`/`Hue`/`Alpha`/`Hex`/`Swatches`를 자식으로 넘긴다.
35
+ */
36
+ children?: React.ReactNode;
37
+ }
38
+
39
+ /* ───────────── color math ───────────── */
40
+
41
+ function clamp(n: number, min: number, max: number) {
42
+ return Math.min(max, Math.max(min, n));
43
+ }
44
+
45
+ function hexToRgb(hex: string): [number, number, number] {
46
+ const m = hex.replace("#", "");
47
+ const full = m.length === 3 ? m.split("").map((c) => c + c).join("") : m;
48
+ const n = parseInt(full, 16);
49
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
50
+ }
51
+
52
+ function rgbToHex(r: number, g: number, b: number): string {
53
+ const toHex = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
54
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
55
+ }
56
+
57
+ function rgbToHsv(r: number, g: number, b: number): HSV {
58
+ const rn = r / 255, gn = g / 255, bn = b / 255;
59
+ const max = Math.max(rn, gn, bn);
60
+ const min = Math.min(rn, gn, bn);
61
+ const d = max - min;
62
+ let h = 0;
63
+ if (d !== 0) {
64
+ if (max === rn) h = ((gn - bn) / d) % 6;
65
+ else if (max === gn) h = (bn - rn) / d + 2;
66
+ else h = (rn - gn) / d + 4;
67
+ h *= 60;
68
+ if (h < 0) h += 360;
69
+ }
70
+ const s = max === 0 ? 0 : d / max;
71
+ const v = max;
72
+ return { h, s, v };
73
+ }
74
+
75
+ function hsvToRgb({ h, s, v }: HSV): [number, number, number] {
76
+ const c = v * s;
77
+ const hh = h / 60;
78
+ const x = c * (1 - Math.abs((hh % 2) - 1));
79
+ let r = 0, g = 0, b = 0;
80
+ if (hh >= 0 && hh < 1) [r, g, b] = [c, x, 0];
81
+ else if (hh < 2) [r, g, b] = [x, c, 0];
82
+ else if (hh < 3) [r, g, b] = [0, c, x];
83
+ else if (hh < 4) [r, g, b] = [0, x, c];
84
+ else if (hh < 5) [r, g, b] = [x, 0, c];
85
+ else [r, g, b] = [c, 0, x];
86
+ const m = v - c;
87
+ return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
88
+ }
89
+
90
+ function hexToHsv(hex: string): HSV {
91
+ const [r, g, b] = hexToRgb(hex);
92
+ return rgbToHsv(r, g, b);
93
+ }
94
+
95
+ function hsvToHex(hsv: HSV): string {
96
+ const [r, g, b] = hsvToRgb(hsv);
97
+ return rgbToHex(r, g, b);
98
+ }
99
+
100
+ const HEX_RE = /^#?[0-9a-f]{6}$/i;
101
+
102
+ /* ───────────── drag hook ───────────── */
103
+
104
+ function useDrag(onMove: (e: PointerEvent, el: HTMLElement) => void) {
105
+ const ref = React.useRef<HTMLDivElement>(null);
106
+
107
+ const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
108
+ const el = ref.current;
109
+ if (!el) return;
110
+ el.setPointerCapture(e.pointerId);
111
+ onMove(e.nativeEvent, el);
112
+
113
+ const onPointerMove = (ev: PointerEvent) => onMove(ev, el);
114
+ const onPointerUp = (ev: PointerEvent) => {
115
+ el.releasePointerCapture(ev.pointerId);
116
+ el.removeEventListener("pointermove", onPointerMove);
117
+ el.removeEventListener("pointerup", onPointerUp);
118
+ };
119
+ el.addEventListener("pointermove", onPointerMove);
120
+ el.addEventListener("pointerup", onPointerUp);
121
+ };
122
+
123
+ return { ref, onPointerDown };
124
+ }
125
+
126
+ /* ───────────── context ───────────── */
127
+
128
+ interface ColorPickerContextValue {
129
+ hsva: HSVA;
130
+ hex: string;
131
+ /** 현재 hue에 해당하는 순색(pure) hex. SV 배경용. */
132
+ pureHueHex: string;
133
+ setHsv: (next: Partial<HSV>) => void;
134
+ setAlpha: (a: number) => void;
135
+ commitHex: (raw: string) => boolean;
136
+ }
137
+
138
+ const ColorPickerContext = React.createContext<ColorPickerContextValue | null>(null);
139
+
140
+ function useColorPicker() {
141
+ const ctx = React.useContext(ColorPickerContext);
142
+ if (!ctx) {
143
+ throw new Error(
144
+ "ColorPicker 하위 컴포넌트는 <ColorPicker> 내부에서만 사용할 수 있습니다.",
145
+ );
146
+ }
147
+ return ctx;
148
+ }
149
+
150
+ /* ───────────── root ───────────── */
151
+
152
+ /**
153
+ * HSV 모델 기반 색상 선택기. children을 생략하면 기본 레이아웃(SV + Hue + Hex)이 자동 렌더되고,
154
+ * 직접 조립하려면 ColorPickerSaturation/Hue/Alpha/Hex/Swatches를 자식으로 넘긴다.
155
+ * 외부 노출값은 항상 6자리 대문자 hex(`#RRGGBB`).
156
+ */
157
+ export function ColorPicker({
158
+ value: valueProp,
159
+ onChange,
160
+ defaultValue = "#000000",
161
+ className,
162
+ children,
163
+ ...rest
164
+ }: ColorPickerProps) {
165
+ const isControlled = valueProp !== undefined;
166
+ const [internal, setInternal] = React.useState(defaultValue);
167
+ const value = isControlled ? valueProp! : internal;
168
+
169
+ const [hsva, setHsva] = React.useState<HSVA>(() => ({ ...hexToHsv(value), a: 1 }));
170
+
171
+ /* 외부 value 변경 시 hsv 동기화 (우리가 내놓은 hex는 무시 — 무한 루프 방지) */
172
+ const lastEmittedRef = React.useRef(value);
173
+ React.useEffect(() => {
174
+ if (value === lastEmittedRef.current) return;
175
+ setHsva((prev) => ({ ...hexToHsv(value), a: prev.a }));
176
+ }, [value]);
177
+
178
+ const emit = React.useCallback(
179
+ (next: HSVA) => {
180
+ const hex = hsvToHex(next);
181
+ lastEmittedRef.current = hex;
182
+ setHsva(next);
183
+ if (!isControlled) setInternal(hex);
184
+ onChange?.(hex);
185
+ },
186
+ [isControlled, onChange],
187
+ );
188
+
189
+ const setHsv = React.useCallback(
190
+ (partial: Partial<HSV>) => {
191
+ const next: HSVA = { ...hsva, ...partial };
192
+ const hex = hsvToHex(next);
193
+ lastEmittedRef.current = hex;
194
+ setHsva(next);
195
+ if (!isControlled) setInternal(hex);
196
+ onChange?.(hex);
197
+ },
198
+ [hsva, isControlled, onChange],
199
+ );
200
+
201
+ const setAlpha = React.useCallback((a: number) => {
202
+ setHsva((prev) => ({ ...prev, a: clamp(a, 0, 1) }));
203
+ }, []);
204
+
205
+ const commitHex = React.useCallback(
206
+ (raw: string) => {
207
+ const v = raw.trim();
208
+ if (!HEX_RE.test(v)) return false;
209
+ const normalized = (v.startsWith("#") ? v : `#${v}`).toUpperCase();
210
+ const nextHsv = hexToHsv(normalized);
211
+ emit({ ...nextHsv, a: hsva.a });
212
+ return true;
213
+ },
214
+ [emit, hsva.a],
215
+ );
216
+
217
+ const pureHueHex = React.useMemo(
218
+ () => hsvToHex({ h: hsva.h, s: 1, v: 1 }),
219
+ [hsva.h],
220
+ );
221
+
222
+ const ctx = React.useMemo<ColorPickerContextValue>(
223
+ () => ({
224
+ hsva,
225
+ hex: value,
226
+ pureHueHex,
227
+ setHsv,
228
+ setAlpha,
229
+ commitHex,
230
+ }),
231
+ [hsva, value, pureHueHex, setHsv, setAlpha, commitHex],
232
+ );
233
+
234
+ return (
235
+ <ColorPickerContext.Provider value={ctx}>
236
+ <div
237
+ className={["sh-ui-color-picker", className].filter(Boolean).join(" ")}
238
+ {...rest}
239
+ >
240
+ {children ?? (
241
+ <>
242
+ <ColorPickerSaturation />
243
+ <ColorPickerHue />
244
+ <ColorPickerHex />
245
+ </>
246
+ )}
247
+ </div>
248
+ </ColorPickerContext.Provider>
249
+ );
250
+ }
251
+
252
+ /* ───────────── parts ───────────── */
253
+
254
+ export interface ColorPickerSaturationProps
255
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onPointerDown"> {}
256
+
257
+ /** 채도(S)와 명도(V)를 동시에 조절하는 2D 박스. 포인터 드래그로 조작. */
258
+ export function ColorPickerSaturation({
259
+ className,
260
+ style,
261
+ ...rest
262
+ }: ColorPickerSaturationProps) {
263
+ const { hsva, hex, pureHueHex, setHsv } = useColorPicker();
264
+ const drag = useDrag((e, el) => {
265
+ const r = el.getBoundingClientRect();
266
+ const x = clamp((e.clientX - r.left) / r.width, 0, 1);
267
+ const y = clamp((e.clientY - r.top) / r.height, 0, 1);
268
+ setHsv({ s: x, v: 1 - y });
269
+ });
270
+ return (
271
+ <div
272
+ ref={drag.ref}
273
+ onPointerDown={drag.onPointerDown}
274
+ className={["sh-ui-color-picker__sv", className].filter(Boolean).join(" ")}
275
+ style={{ background: pureHueHex, ...style }}
276
+ role="slider"
277
+ aria-label="채도/명도"
278
+ aria-valuemin={0}
279
+ aria-valuemax={100}
280
+ aria-valuenow={Math.round(hsva.s * 100)}
281
+ {...rest}
282
+ >
283
+ <div className="sh-ui-color-picker__sv-saturation" />
284
+ <div className="sh-ui-color-picker__sv-value" />
285
+ <div
286
+ className="sh-ui-color-picker__sv-thumb"
287
+ style={{
288
+ left: `${hsva.s * 100}%`,
289
+ top: `${(1 - hsva.v) * 100}%`,
290
+ background: hex,
291
+ }}
292
+ />
293
+ </div>
294
+ );
295
+ }
296
+
297
+ export interface ColorPickerHueProps
298
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onPointerDown"> {}
299
+
300
+ /** 색상(H, 0~360°) 슬라이더. 무지개 그라데이션 위에 thumb이 위치. */
301
+ export function ColorPickerHue({ className, ...rest }: ColorPickerHueProps) {
302
+ const { hsva, setHsv } = useColorPicker();
303
+ const drag = useDrag((e, el) => {
304
+ const r = el.getBoundingClientRect();
305
+ const x = clamp((e.clientX - r.left) / r.width, 0, 1);
306
+ setHsv({ h: x * 360 });
307
+ });
308
+ return (
309
+ <div
310
+ ref={drag.ref}
311
+ onPointerDown={drag.onPointerDown}
312
+ className={["sh-ui-color-picker__hue", className].filter(Boolean).join(" ")}
313
+ role="slider"
314
+ aria-label="색조"
315
+ aria-valuemin={0}
316
+ aria-valuemax={360}
317
+ aria-valuenow={Math.round(hsva.h)}
318
+ {...rest}
319
+ >
320
+ <div
321
+ className="sh-ui-color-picker__hue-thumb"
322
+ style={{ left: `${(hsva.h / 360) * 100}%` }}
323
+ />
324
+ </div>
325
+ );
326
+ }
327
+
328
+ export interface ColorPickerAlphaProps
329
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onPointerDown"> {}
330
+
331
+ /** 투명도(A, 0~100%) 슬라이더. 외부에 알파를 노출하지 않는 hex 모드와는 시각 표시용. */
332
+ export function ColorPickerAlpha({ className, style, ...rest }: ColorPickerAlphaProps) {
333
+ const { hsva, hex, setAlpha } = useColorPicker();
334
+ const drag = useDrag((e, el) => {
335
+ const r = el.getBoundingClientRect();
336
+ const x = clamp((e.clientX - r.left) / r.width, 0, 1);
337
+ setAlpha(x);
338
+ });
339
+ const gradient = `linear-gradient(to right, rgba(0,0,0,0) 0%, ${hex} 100%)`;
340
+ return (
341
+ <div
342
+ ref={drag.ref}
343
+ onPointerDown={drag.onPointerDown}
344
+ className={["sh-ui-color-picker__alpha", className].filter(Boolean).join(" ")}
345
+ role="slider"
346
+ aria-label="투명도"
347
+ aria-valuemin={0}
348
+ aria-valuemax={100}
349
+ aria-valuenow={Math.round(hsva.a * 100)}
350
+ style={style}
351
+ {...rest}
352
+ >
353
+ <div
354
+ className="sh-ui-color-picker__alpha-track"
355
+ style={{ backgroundImage: gradient }}
356
+ />
357
+ <div
358
+ className="sh-ui-color-picker__hue-thumb"
359
+ style={{ left: `${hsva.a * 100}%` }}
360
+ />
361
+ </div>
362
+ );
363
+ }
364
+
365
+ export interface ColorPickerHexProps
366
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
367
+ /**
368
+ * input 좌측에 현재 색상 미리보기 swatch 표시 여부.
369
+ * @default true
370
+ */
371
+ showSwatch?: boolean;
372
+ }
373
+
374
+ /** Hex 직접 입력 + 좌측 swatch. blur·Enter 시 검증·커밋되며 잘못된 값은 이전 값으로 되돌린다. */
375
+ export function ColorPickerHex({
376
+ className,
377
+ showSwatch = true,
378
+ ...rest
379
+ }: ColorPickerHexProps) {
380
+ const { hex, commitHex } = useColorPicker();
381
+ const [draft, setDraft] = React.useState(hex);
382
+
383
+ // 외부 hex 변경 시 draft 동기화 (단, 포커스 중이 아닐 때만)
384
+ const inputRef = React.useRef<HTMLInputElement>(null);
385
+ React.useEffect(() => {
386
+ if (document.activeElement !== inputRef.current) setDraft(hex);
387
+ }, [hex]);
388
+
389
+ const onCommit = () => {
390
+ if (!commitHex(draft)) setDraft(hex);
391
+ };
392
+
393
+ return (
394
+ <div
395
+ className={["sh-ui-color-picker__row", className].filter(Boolean).join(" ")}
396
+ {...rest}
397
+ >
398
+ {showSwatch && (
399
+ <div
400
+ className="sh-ui-color-picker__swatch"
401
+ style={{ background: hex }}
402
+ aria-hidden
403
+ />
404
+ )}
405
+ <input
406
+ ref={inputRef}
407
+ type="text"
408
+ className="sh-ui-color-picker__hex"
409
+ value={draft}
410
+ onChange={(e) => setDraft(e.target.value)}
411
+ onBlur={onCommit}
412
+ onKeyDown={(e) => {
413
+ if (e.key === "Enter") {
414
+ e.preventDefault();
415
+ (e.target as HTMLInputElement).blur();
416
+ }
417
+ }}
418
+ spellCheck={false}
419
+ aria-label="Hex"
420
+ />
421
+ </div>
422
+ );
423
+ }
424
+
425
+ export interface ColorPickerSwatchesProps
426
+ extends React.HTMLAttributes<HTMLDivElement> {
427
+ /**
428
+ * 표시할 hex 색상 목록. 항목 클릭 시 해당 색상으로 즉시 commit된다.
429
+ * 형식은 `"#RRGGBB"` 권장 (입력 시 대소문자는 자동 정규화).
430
+ */
431
+ colors: string[];
432
+ }
433
+
434
+ /** 미리 정의된 색상 팔레트 그리드. 각 항목 클릭 시 그 색상으로 즉시 커밋한다. */
435
+ export function ColorPickerSwatches({
436
+ className,
437
+ colors,
438
+ ...rest
439
+ }: ColorPickerSwatchesProps) {
440
+ const { hex, commitHex } = useColorPicker();
441
+ return (
442
+ <div
443
+ role="group"
444
+ aria-label="미리 준비된 색상"
445
+ className={["sh-ui-color-picker__swatches", className].filter(Boolean).join(" ")}
446
+ {...rest}
447
+ >
448
+ {colors.map((c) => {
449
+ const normalized = c.toUpperCase();
450
+ const selected = normalized === hex.toUpperCase();
451
+ return (
452
+ <button
453
+ key={c}
454
+ type="button"
455
+ className="sh-ui-color-picker__swatch-btn"
456
+ aria-label={c}
457
+ aria-pressed={selected}
458
+ data-selected={selected || undefined}
459
+ style={{ background: c }}
460
+ onClick={() => commitHex(c)}
461
+ />
462
+ );
463
+ })}
464
+ </div>
465
+ );
466
+ }
@@ -0,0 +1,166 @@
1
+ .sh-ui-color-picker {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 0.625rem;
5
+ width: 100%;
6
+ user-select: none;
7
+ -webkit-user-select: none;
8
+ }
9
+
10
+ /* ───────────── SV 영역 ───────────── */
11
+ .sh-ui-color-picker__sv {
12
+ position: relative;
13
+ width: 100%;
14
+ aspect-ratio: 4 / 3;
15
+ border-radius: var(--radius);
16
+ cursor: crosshair;
17
+ overflow: hidden;
18
+ touch-action: none;
19
+ }
20
+
21
+ /* 좌→우 흰색 그라데이션 (saturation) */
22
+ .sh-ui-color-picker__sv-saturation {
23
+ position: absolute;
24
+ inset: 0;
25
+ background: linear-gradient(to right, #fff, transparent);
26
+ }
27
+
28
+ /* 위→아래 검정 그라데이션 (value) */
29
+ .sh-ui-color-picker__sv-value {
30
+ position: absolute;
31
+ inset: 0;
32
+ background: linear-gradient(to top, #000, transparent);
33
+ }
34
+
35
+ .sh-ui-color-picker__sv-thumb {
36
+ position: absolute;
37
+ width: 0.875rem;
38
+ height: 0.875rem;
39
+ margin-left: -0.4375rem;
40
+ margin-top: -0.4375rem;
41
+ border: 2px solid #fff;
42
+ border-radius: 50%;
43
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4);
44
+ pointer-events: none;
45
+ }
46
+
47
+ /* ───────────── Hue 슬라이더 ───────────── */
48
+ .sh-ui-color-picker__hue {
49
+ position: relative;
50
+ width: 100%;
51
+ height: 0.875rem;
52
+ border-radius: 999px;
53
+ cursor: pointer;
54
+ touch-action: none;
55
+ background: linear-gradient(
56
+ to right,
57
+ #f00 0%,
58
+ #ff0 16.66%,
59
+ #0f0 33.33%,
60
+ #0ff 50%,
61
+ #00f 66.66%,
62
+ #f0f 83.33%,
63
+ #f00 100%
64
+ );
65
+ }
66
+
67
+ .sh-ui-color-picker__hue-thumb {
68
+ position: absolute;
69
+ top: 50%;
70
+ width: 0.875rem;
71
+ height: 0.875rem;
72
+ margin-left: -0.4375rem;
73
+ transform: translateY(-50%);
74
+ background: #fff;
75
+ border-radius: 50%;
76
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4);
77
+ pointer-events: none;
78
+ }
79
+
80
+ /* ───────────── Alpha 슬라이더 ───────────── */
81
+ .sh-ui-color-picker__alpha {
82
+ position: relative;
83
+ width: 100%;
84
+ height: 0.875rem;
85
+ border-radius: 999px;
86
+ cursor: pointer;
87
+ touch-action: none;
88
+ /* 체크보드 배경 — 투명도 가시화 */
89
+ background-color: #fff;
90
+ background-image:
91
+ linear-gradient(45deg, #ccc 25%, transparent 25%),
92
+ linear-gradient(-45deg, #ccc 25%, transparent 25%),
93
+ linear-gradient(45deg, transparent 75%, #ccc 75%),
94
+ linear-gradient(-45deg, transparent 75%, #ccc 75%);
95
+ background-size: 8px 8px;
96
+ background-position: 0 0, 0 4px, 4px -4px, -4px 0;
97
+ overflow: hidden;
98
+ }
99
+
100
+ .sh-ui-color-picker__alpha-track {
101
+ position: absolute;
102
+ inset: 0;
103
+ border-radius: inherit;
104
+ pointer-events: none;
105
+ }
106
+
107
+ /* ───────────── Hex 인풋 ───────────── */
108
+ .sh-ui-color-picker__row {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: var(--space-2);
112
+ }
113
+
114
+ .sh-ui-color-picker__swatch {
115
+ width: 1.75rem;
116
+ height: 1.75rem;
117
+ border-radius: calc(var(--radius) - 2px);
118
+ border: 1px solid var(--border);
119
+ flex-shrink: 0;
120
+ }
121
+
122
+ .sh-ui-color-picker__hex {
123
+ flex: 1;
124
+ min-width: 0;
125
+ height: 1.75rem;
126
+ padding: 0 var(--space-2);
127
+ border: 1px solid var(--border);
128
+ border-radius: calc(var(--radius) - 2px);
129
+ background: var(--background);
130
+ color: var(--foreground);
131
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
132
+ font-size: 0.8125rem;
133
+ text-transform: uppercase;
134
+ }
135
+ .sh-ui-color-picker__hex:focus {
136
+ outline: none;
137
+ border-color: var(--foreground);
138
+ box-shadow: 0 0 0 1px var(--foreground);
139
+ }
140
+
141
+ /* ───────────── Swatches ───────────── */
142
+ .sh-ui-color-picker__swatches {
143
+ display: flex;
144
+ flex-wrap: wrap;
145
+ gap: 0.375rem;
146
+ }
147
+
148
+ .sh-ui-color-picker__swatch-btn {
149
+ width: 1.25rem;
150
+ height: 1.25rem;
151
+ padding: 0;
152
+ border: 1px solid var(--border);
153
+ border-radius: calc(var(--radius) - 4px);
154
+ cursor: pointer;
155
+ transition: transform var(--duration-fast), box-shadow var(--duration-fast);
156
+ }
157
+ .sh-ui-color-picker__swatch-btn:hover {
158
+ transform: scale(1.08);
159
+ }
160
+ .sh-ui-color-picker__swatch-btn:focus-visible {
161
+ outline: var(--border-width-strong) solid var(--foreground);
162
+ outline-offset: 2px;
163
+ }
164
+ .sh-ui-color-picker__swatch-btn[data-selected] {
165
+ box-shadow: 0 0 0 2px var(--background), 0 0 0 3.5px var(--foreground);
166
+ }