@proyecto-viviana/solid-stately 0.2.4 → 0.3.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 (243) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -283
  3. package/dist/autocomplete/createAutocompleteState.d.ts +3 -2
  4. package/dist/autocomplete/index.d.ts +1 -1
  5. package/dist/calendar/createCalendarState.d.ts +22 -7
  6. package/dist/calendar/createDateFieldState.d.ts +30 -7
  7. package/dist/calendar/createDatePickerState.d.ts +60 -0
  8. package/dist/calendar/createRangeCalendarState.d.ts +27 -8
  9. package/dist/calendar/createTimeFieldState.d.ts +34 -7
  10. package/dist/calendar/index.d.ts +7 -6
  11. package/dist/checkbox/createCheckboxGroupState.d.ts +12 -3
  12. package/dist/checkbox/index.d.ts +1 -1
  13. package/dist/collections/ListCollection.d.ts +1 -1
  14. package/dist/collections/createListState.d.ts +8 -8
  15. package/dist/collections/createMenuState.d.ts +7 -7
  16. package/dist/collections/createSelectionState.d.ts +5 -5
  17. package/dist/collections/index.d.ts +5 -5
  18. package/dist/collections/types.d.ts +23 -12
  19. package/dist/color/Color.d.ts +1 -1
  20. package/dist/color/createColorAreaState.d.ts +4 -2
  21. package/dist/color/createColorFieldState.d.ts +38 -12
  22. package/dist/color/createColorSliderState.d.ts +9 -2
  23. package/dist/color/createColorWheelState.d.ts +13 -2
  24. package/dist/color/getColorChannels.d.ts +20 -0
  25. package/dist/color/index.d.ts +6 -6
  26. package/dist/color/types.d.ts +4 -4
  27. package/dist/combobox/createComboBoxState.d.ts +27 -13
  28. package/dist/combobox/index.d.ts +1 -1
  29. package/dist/data/createAsyncList.d.ts +111 -0
  30. package/dist/data/createListData.d.ts +65 -0
  31. package/dist/data/createTreeData.d.ts +65 -0
  32. package/dist/data/index.d.ts +3 -0
  33. package/dist/datepicker/index.d.ts +8 -0
  34. package/dist/disclosure/createDisclosureState.d.ts +2 -2
  35. package/dist/disclosure/index.d.ts +1 -1
  36. package/dist/dnd/createDragState.d.ts +2 -2
  37. package/dist/dnd/createDraggableCollectionState.d.ts +2 -2
  38. package/dist/dnd/createDropState.d.ts +2 -2
  39. package/dist/dnd/createDroppableCollectionState.d.ts +3 -3
  40. package/dist/dnd/index.d.ts +5 -5
  41. package/dist/dnd/types.d.ts +16 -16
  42. package/dist/form/createFormValidationState.d.ts +3 -3
  43. package/dist/form/index.d.ts +1 -1
  44. package/dist/grid/createGridState.d.ts +2 -2
  45. package/dist/grid/index.d.ts +2 -2
  46. package/dist/grid/types.d.ts +17 -11
  47. package/dist/index.d.ts +31 -25
  48. package/dist/index.js +7990 -6264
  49. package/dist/index.js.map +1 -7
  50. package/dist/menu/index.d.ts +8 -0
  51. package/dist/numberfield/createNumberFieldState.d.ts +2 -2
  52. package/dist/numberfield/index.d.ts +1 -1
  53. package/dist/overlays/createOverlayTriggerState.d.ts +2 -2
  54. package/dist/overlays/index.d.ts +1 -1
  55. package/dist/radio/createRadioGroupState.d.ts +13 -4
  56. package/dist/radio/index.d.ts +1 -1
  57. package/dist/searchfield/createSearchFieldState.d.ts +2 -2
  58. package/dist/searchfield/index.d.ts +2 -2
  59. package/dist/select/createSelectState.d.ts +20 -3
  60. package/dist/select/index.d.ts +1 -1
  61. package/dist/selection/index.d.ts +11 -0
  62. package/dist/slider/createSliderState.d.ts +3 -3
  63. package/dist/slider/index.d.ts +2 -2
  64. package/dist/steplist/createStepListState.d.ts +48 -0
  65. package/dist/steplist/index.d.ts +1 -0
  66. package/dist/table/TableCollection.d.ts +3 -3
  67. package/dist/table/createTableColumnResizeState.d.ts +54 -0
  68. package/dist/table/createTableState.d.ts +2 -2
  69. package/dist/table/index.d.ts +5 -3
  70. package/dist/table/types.d.ts +10 -10
  71. package/dist/tabs/createTabListState.d.ts +5 -5
  72. package/dist/tabs/index.d.ts +1 -1
  73. package/dist/textfield/createTextFieldState.d.ts +2 -2
  74. package/dist/textfield/index.d.ts +1 -1
  75. package/dist/toast/createToastState.d.ts +15 -3
  76. package/dist/toast/index.d.ts +1 -1
  77. package/dist/toggle/createToggleGroupState.d.ts +45 -0
  78. package/dist/toggle/createToggleState.d.ts +2 -2
  79. package/dist/toggle/index.d.ts +2 -1
  80. package/dist/tooltip/createTooltipTriggerState.d.ts +2 -2
  81. package/dist/tooltip/index.d.ts +1 -1
  82. package/dist/tree/TreeCollection.d.ts +5 -4
  83. package/dist/tree/createTreeState.d.ts +2 -2
  84. package/dist/tree/index.d.ts +3 -3
  85. package/dist/tree/types.d.ts +19 -13
  86. package/dist/utils/index.d.ts +1 -1
  87. package/dist/utils/reactivity.d.ts +1 -1
  88. package/package.json +30 -30
  89. package/src/autocomplete/createAutocompleteState.d.ts +19 -17
  90. package/src/autocomplete/createAutocompleteState.ts +25 -32
  91. package/src/autocomplete/index.d.ts +6 -2
  92. package/src/autocomplete/index.ts +1 -1
  93. package/src/calendar/createCalendarState.d.ts +139 -117
  94. package/src/calendar/createCalendarState.ts +221 -74
  95. package/src/calendar/createDateFieldState.d.ts +130 -95
  96. package/src/calendar/createDateFieldState.ts +286 -151
  97. package/src/calendar/createDatePickerState.ts +347 -0
  98. package/src/calendar/createRangeCalendarState.d.ts +154 -129
  99. package/src/calendar/createRangeCalendarState.ts +293 -79
  100. package/src/calendar/createTimeFieldState.d.ts +81 -79
  101. package/src/calendar/createTimeFieldState.ts +187 -108
  102. package/src/calendar/index.d.ts +80 -7
  103. package/src/calendar/index.ts +21 -12
  104. package/src/checkbox/createCheckboxGroupState.d.ts +57 -55
  105. package/src/checkbox/createCheckboxGroupState.ts +57 -30
  106. package/src/checkbox/index.d.ts +6 -2
  107. package/src/checkbox/index.ts +1 -1
  108. package/src/collections/ListCollection.d.ts +25 -22
  109. package/src/collections/ListCollection.ts +152 -146
  110. package/src/collections/createListState.d.ts +73 -58
  111. package/src/collections/createListState.ts +267 -264
  112. package/src/collections/createMenuState.d.ts +36 -31
  113. package/src/collections/createMenuState.ts +112 -106
  114. package/src/collections/createSelectionState.d.ts +78 -65
  115. package/src/collections/createSelectionState.ts +340 -336
  116. package/src/collections/index.d.ts +38 -6
  117. package/src/collections/index.ts +41 -46
  118. package/src/collections/types.d.ts +101 -101
  119. package/src/collections/types.ts +181 -169
  120. package/src/color/Color.d.ts +20 -5
  121. package/src/color/Color.ts +1054 -951
  122. package/src/color/createColorAreaState.d.ts +67 -65
  123. package/src/color/createColorAreaState.ts +301 -293
  124. package/src/color/createColorFieldState.d.ts +46 -44
  125. package/src/color/createColorFieldState.ts +428 -292
  126. package/src/color/createColorSliderState.d.ts +58 -56
  127. package/src/color/createColorSliderState.ts +272 -241
  128. package/src/color/createColorWheelState.d.ts +42 -40
  129. package/src/color/createColorWheelState.ts +248 -211
  130. package/src/color/getColorChannels.ts +36 -0
  131. package/src/color/index.d.ts +36 -7
  132. package/src/color/index.ts +44 -47
  133. package/src/color/types.d.ts +68 -63
  134. package/src/color/types.ts +119 -127
  135. package/src/combobox/createComboBoxState.d.ts +107 -105
  136. package/src/combobox/createComboBoxState.ts +790 -703
  137. package/src/combobox/index.d.ts +10 -2
  138. package/src/combobox/index.ts +13 -13
  139. package/src/data/createAsyncList.ts +403 -0
  140. package/src/data/createListData.ts +298 -0
  141. package/src/data/createTreeData.ts +646 -0
  142. package/src/data/index.ts +25 -0
  143. package/src/datepicker/index.ts +32 -0
  144. package/src/disclosure/createDisclosureState.d.ts +47 -43
  145. package/src/disclosure/createDisclosureState.ts +9 -23
  146. package/src/disclosure/index.d.ts +10 -2
  147. package/src/disclosure/index.ts +1 -1
  148. package/src/dnd/createDragState.d.ts +51 -42
  149. package/src/dnd/createDragState.ts +151 -153
  150. package/src/dnd/createDraggableCollectionState.d.ts +52 -41
  151. package/src/dnd/createDraggableCollectionState.ts +158 -165
  152. package/src/dnd/createDropState.d.ts +64 -45
  153. package/src/dnd/createDropState.ts +202 -212
  154. package/src/dnd/createDroppableCollectionState.d.ts +88 -59
  155. package/src/dnd/createDroppableCollectionState.ts +354 -357
  156. package/src/dnd/index.d.ts +54 -6
  157. package/src/dnd/index.ts +63 -76
  158. package/src/dnd/types.d.ts +167 -156
  159. package/src/dnd/types.ts +316 -317
  160. package/src/form/createFormValidationState.d.ts +47 -45
  161. package/src/form/createFormValidationState.ts +356 -389
  162. package/src/form/index.d.ts +16 -2
  163. package/src/form/index.ts +15 -15
  164. package/src/grid/createGridState.d.ts +7 -4
  165. package/src/grid/createGridState.ts +44 -51
  166. package/src/grid/index.d.ts +3 -3
  167. package/src/grid/index.ts +2 -8
  168. package/src/grid/types.d.ts +136 -129
  169. package/src/grid/types.ts +24 -17
  170. package/src/index.d.ts +310 -26
  171. package/src/index.ts +87 -79
  172. package/src/menu/index.ts +17 -0
  173. package/src/numberfield/createNumberFieldState.d.ts +56 -54
  174. package/src/numberfield/createNumberFieldState.ts +422 -383
  175. package/src/numberfield/index.d.ts +6 -2
  176. package/src/numberfield/index.ts +5 -5
  177. package/src/overlays/createOverlayTriggerState.d.ts +22 -20
  178. package/src/overlays/createOverlayTriggerState.ts +66 -67
  179. package/src/overlays/index.d.ts +6 -2
  180. package/src/overlays/index.ts +5 -5
  181. package/src/radio/createRadioGroupState.d.ts +54 -52
  182. package/src/radio/createRadioGroupState.ts +66 -30
  183. package/src/radio/index.d.ts +7 -2
  184. package/src/radio/index.ts +1 -1
  185. package/src/searchfield/createSearchFieldState.d.ts +16 -14
  186. package/src/searchfield/createSearchFieldState.ts +56 -62
  187. package/src/searchfield/index.d.ts +3 -3
  188. package/src/searchfield/index.ts +2 -5
  189. package/src/select/createSelectState.d.ts +63 -61
  190. package/src/select/createSelectState.ts +290 -181
  191. package/src/select/index.d.ts +2 -2
  192. package/src/select/index.ts +1 -5
  193. package/src/selection/index.ts +27 -0
  194. package/src/slider/createSliderState.d.ts +60 -60
  195. package/src/slider/createSliderState.ts +233 -211
  196. package/src/slider/index.d.ts +3 -3
  197. package/src/slider/index.ts +2 -6
  198. package/src/ssr/index.d.ts +1 -1
  199. package/src/ssr/index.ts +2 -2
  200. package/src/steplist/createStepListState.ts +191 -0
  201. package/src/steplist/index.ts +5 -0
  202. package/src/table/TableCollection.d.ts +42 -40
  203. package/src/table/TableCollection.ts +61 -64
  204. package/src/table/createTableColumnResizeState.ts +242 -0
  205. package/src/table/createTableState.d.ts +7 -4
  206. package/src/table/createTableState.ts +10 -7
  207. package/src/table/index.d.ts +14 -4
  208. package/src/table/index.ts +10 -3
  209. package/src/table/types.d.ts +98 -92
  210. package/src/table/types.ts +17 -13
  211. package/src/tabs/createTabListState.d.ts +59 -57
  212. package/src/tabs/createTabListState.ts +58 -39
  213. package/src/tabs/index.d.ts +8 -2
  214. package/src/tabs/index.ts +1 -1
  215. package/src/textfield/createTextFieldState.d.ts +16 -14
  216. package/src/textfield/createTextFieldState.ts +7 -18
  217. package/src/textfield/index.d.ts +6 -2
  218. package/src/textfield/index.ts +1 -1
  219. package/src/toast/createToastState.d.ts +92 -81
  220. package/src/toast/createToastState.ts +56 -44
  221. package/src/toast/index.d.ts +12 -2
  222. package/src/toast/index.ts +1 -1
  223. package/src/toggle/createToggleGroupState.ts +117 -0
  224. package/src/toggle/createToggleState.d.ts +19 -19
  225. package/src/toggle/createToggleState.ts +2 -16
  226. package/src/toggle/index.d.ts +2 -2
  227. package/src/toggle/index.ts +6 -4
  228. package/src/tooltip/createTooltipTriggerState.d.ts +17 -17
  229. package/src/tooltip/createTooltipTriggerState.ts +184 -183
  230. package/src/tooltip/index.d.ts +7 -2
  231. package/src/tooltip/index.ts +6 -6
  232. package/src/tree/TreeCollection.d.ts +26 -23
  233. package/src/tree/TreeCollection.ts +215 -175
  234. package/src/tree/createTreeState.d.ts +7 -4
  235. package/src/tree/createTreeState.ts +390 -392
  236. package/src/tree/index.d.ts +10 -4
  237. package/src/tree/index.ts +13 -13
  238. package/src/tree/types.d.ts +131 -126
  239. package/src/tree/types.ts +183 -174
  240. package/src/utils/index.d.ts +2 -2
  241. package/src/utils/index.ts +1 -1
  242. package/src/utils/reactivity.d.ts +2 -2
  243. package/src/utils/reactivity.ts +3 -3
@@ -1,951 +1,1054 @@
1
- /**
2
- * Color class implementation.
3
- * Based on @react-stately/color.
4
- *
5
- * Provides color manipulation, conversion, and formatting.
6
- */
7
-
8
- import type {
9
- Color,
10
- ColorFormat,
11
- ColorSpace,
12
- ColorChannel,
13
- ColorChannelRange,
14
- ColorAxes,
15
- RGBColor,
16
- HSLColor,
17
- HSBColor,
18
- } from './types';
19
-
20
- // Channel ranges
21
- const RGB_CHANNEL_RANGE: ColorChannelRange = {
22
- minValue: 0,
23
- maxValue: 255,
24
- step: 1,
25
- pageSize: 17,
26
- };
27
-
28
- const ALPHA_CHANNEL_RANGE: ColorChannelRange = {
29
- minValue: 0,
30
- maxValue: 1,
31
- step: 0.01,
32
- pageSize: 0.1,
33
- };
34
-
35
- const HUE_CHANNEL_RANGE: ColorChannelRange = {
36
- minValue: 0,
37
- maxValue: 360,
38
- step: 1,
39
- pageSize: 15,
40
- };
41
-
42
- const PERCENT_CHANNEL_RANGE: ColorChannelRange = {
43
- minValue: 0,
44
- maxValue: 100,
45
- step: 1,
46
- pageSize: 10,
47
- };
48
-
49
- // Channel names (English only for now)
50
- const CHANNEL_NAMES: Record<ColorChannel, string> = {
51
- hue: 'Hue',
52
- saturation: 'Saturation',
53
- brightness: 'Brightness',
54
- lightness: 'Lightness',
55
- red: 'Red',
56
- green: 'Green',
57
- blue: 'Blue',
58
- alpha: 'Alpha',
59
- };
60
-
61
- // Hue names for color naming
62
- const HUE_NAMES: Array<{ max: number; name: string }> = [
63
- { max: 15, name: 'pink' },
64
- { max: 48, name: 'red' },
65
- { max: 94, name: 'orange' },
66
- { max: 135, name: 'yellow' },
67
- { max: 175, name: 'green' },
68
- { max: 264, name: 'cyan' },
69
- { max: 284, name: 'blue' },
70
- { max: 320, name: 'purple' },
71
- { max: 349, name: 'magenta' },
72
- { max: 360, name: 'pink' },
73
- ];
74
-
75
- /**
76
- * Clamp a value to a range.
77
- */
78
- function clamp(value: number, min: number, max: number): number {
79
- return Math.min(Math.max(value, min), max);
80
- }
81
-
82
- /**
83
- * Round to fixed decimal places.
84
- */
85
- function toFixed(value: number, decimals: number): number {
86
- return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
87
- }
88
-
89
- /**
90
- * Convert RGB to HSL.
91
- */
92
- function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
93
- r /= 255;
94
- g /= 255;
95
- b /= 255;
96
-
97
- const max = Math.max(r, g, b);
98
- const min = Math.min(r, g, b);
99
- let h = 0;
100
- let s = 0;
101
- const l = (max + min) / 2;
102
-
103
- if (max !== min) {
104
- const d = max - min;
105
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
106
-
107
- switch (max) {
108
- case r:
109
- h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
110
- break;
111
- case g:
112
- h = ((b - r) / d + 2) / 6;
113
- break;
114
- case b:
115
- h = ((r - g) / d + 4) / 6;
116
- break;
117
- }
118
- }
119
-
120
- return {
121
- h: Math.round(h * 360),
122
- s: Math.round(s * 100),
123
- l: Math.round(l * 100),
124
- };
125
- }
126
-
127
- /**
128
- * Convert HSL to RGB.
129
- */
130
- function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
131
- h /= 360;
132
- s /= 100;
133
- l /= 100;
134
-
135
- let r: number, g: number, b: number;
136
-
137
- if (s === 0) {
138
- r = g = b = l;
139
- } else {
140
- const hue2rgb = (p: number, q: number, t: number) => {
141
- if (t < 0) t += 1;
142
- if (t > 1) t -= 1;
143
- if (t < 1 / 6) return p + (q - p) * 6 * t;
144
- if (t < 1 / 2) return q;
145
- if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
146
- return p;
147
- };
148
-
149
- const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
150
- const p = 2 * l - q;
151
- r = hue2rgb(p, q, h + 1 / 3);
152
- g = hue2rgb(p, q, h);
153
- b = hue2rgb(p, q, h - 1 / 3);
154
- }
155
-
156
- return {
157
- r: Math.round(r * 255),
158
- g: Math.round(g * 255),
159
- b: Math.round(b * 255),
160
- };
161
- }
162
-
163
- /**
164
- * Convert RGB to HSB.
165
- */
166
- function rgbToHsb(r: number, g: number, b: number): { h: number; s: number; b: number } {
167
- r /= 255;
168
- g /= 255;
169
- b /= 255;
170
-
171
- const max = Math.max(r, g, b);
172
- const min = Math.min(r, g, b);
173
- let h = 0;
174
- const v = max;
175
- const d = max - min;
176
- const s = max === 0 ? 0 : d / max;
177
-
178
- if (max !== min) {
179
- switch (max) {
180
- case r:
181
- h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
182
- break;
183
- case g:
184
- h = ((b - r) / d + 2) / 6;
185
- break;
186
- case b:
187
- h = ((r - g) / d + 4) / 6;
188
- break;
189
- }
190
- }
191
-
192
- return {
193
- h: Math.round(h * 360),
194
- s: Math.round(s * 100),
195
- b: Math.round(v * 100),
196
- };
197
- }
198
-
199
- /**
200
- * Convert HSB to RGB.
201
- */
202
- function hsbToRgb(h: number, s: number, b: number): { r: number; g: number; b: number } {
203
- h /= 360;
204
- s /= 100;
205
- b /= 100;
206
-
207
- let r: number, g: number, bl: number;
208
-
209
- const i = Math.floor(h * 6);
210
- const f = h * 6 - i;
211
- const p = b * (1 - s);
212
- const q = b * (1 - f * s);
213
- const t = b * (1 - (1 - f) * s);
214
-
215
- switch (i % 6) {
216
- case 0:
217
- r = b;
218
- g = t;
219
- bl = p;
220
- break;
221
- case 1:
222
- r = q;
223
- g = b;
224
- bl = p;
225
- break;
226
- case 2:
227
- r = p;
228
- g = b;
229
- bl = t;
230
- break;
231
- case 3:
232
- r = p;
233
- g = q;
234
- bl = b;
235
- break;
236
- case 4:
237
- r = t;
238
- g = p;
239
- bl = b;
240
- break;
241
- default:
242
- r = b;
243
- g = p;
244
- bl = q;
245
- break;
246
- }
247
-
248
- return {
249
- r: Math.round(r * 255),
250
- g: Math.round(g * 255),
251
- b: Math.round(bl * 255),
252
- };
253
- }
254
-
255
- /**
256
- * Get hue name from hue angle.
257
- */
258
- function getHueNameFromAngle(hue: number): string {
259
- for (const { max, name } of HUE_NAMES) {
260
- if (hue <= max) {
261
- return name;
262
- }
263
- }
264
- return 'pink';
265
- }
266
-
267
- /**
268
- * Get a human-readable color name based on RGB values.
269
- */
270
- function getColorNameFromRgb(r: number, g: number, b: number, alpha: number): string {
271
- // Convert to HSL for analysis
272
- const { h, s, l } = rgbToHsl(r, g, b);
273
-
274
- // Handle edge cases
275
- if (l >= 100) {
276
- return alpha < 1 ? `white ${Math.round(alpha * 100)}% transparent` : 'white';
277
- }
278
- if (l <= 0) {
279
- return alpha < 1 ? `black ${Math.round(alpha * 100)}% transparent` : 'black';
280
- }
281
-
282
- // Build color name
283
- const parts: string[] = [];
284
-
285
- // Lightness descriptor
286
- if (l < 30) {
287
- parts.push('very dark');
288
- } else if (l < 55) {
289
- parts.push('dark');
290
- } else if (l > 85) {
291
- parts.push('very light');
292
- } else if (l > 70) {
293
- parts.push('light');
294
- }
295
-
296
- // Saturation/chroma descriptor
297
- if (s < 10) {
298
- parts.push('gray');
299
- } else if (s < 30) {
300
- parts.push('grayish');
301
- } else if (s > 80) {
302
- parts.push('vibrant');
303
- }
304
-
305
- // Hue name (skip if gray)
306
- if (s >= 10) {
307
- let hueName = getHueNameFromAngle(h);
308
-
309
- // Special cases
310
- if (hueName === 'orange' && l < 68) {
311
- hueName = 'brown';
312
- }
313
- if (hueName === 'yellow' && l < 85 && s > 50) {
314
- hueName = 'yellow green';
315
- }
316
-
317
- parts.push(hueName);
318
- }
319
-
320
- let name = parts.join(' ');
321
-
322
- // Add transparency
323
- if (alpha < 1) {
324
- name += ` ${Math.round(alpha * 100)}% transparent`;
325
- }
326
-
327
- return name || 'color';
328
- }
329
-
330
- /**
331
- * RGB Color implementation.
332
- */
333
- class RGBColorImpl implements Color {
334
- private red: number;
335
- private green: number;
336
- private blue: number;
337
- private alpha: number;
338
-
339
- constructor(red: number, green: number, blue: number, alpha: number = 1) {
340
- this.red = clamp(Math.round(red), 0, 255);
341
- this.green = clamp(Math.round(green), 0, 255);
342
- this.blue = clamp(Math.round(blue), 0, 255);
343
- this.alpha = clamp(toFixed(alpha, 2), 0, 1);
344
- }
345
-
346
- toFormat(format: ColorFormat): Color {
347
- switch (format) {
348
- case 'hex':
349
- case 'hexa':
350
- case 'rgb':
351
- case 'rgba':
352
- return this.clone();
353
- case 'hsl':
354
- case 'hsla': {
355
- const { h, s, l } = rgbToHsl(this.red, this.green, this.blue);
356
- return new HSLColorImpl(h, s, l, this.alpha);
357
- }
358
- case 'hsb':
359
- case 'hsba': {
360
- const { h, s, b } = rgbToHsb(this.red, this.green, this.blue);
361
- return new HSBColorImpl(h, s, b, this.alpha);
362
- }
363
- default:
364
- throw new Error(`Unsupported format: ${format}`);
365
- }
366
- }
367
-
368
- toString(format?: ColorFormat | 'css'): string {
369
- const f = format ?? 'css';
370
-
371
- switch (f) {
372
- case 'hex':
373
- return `#${this.red.toString(16).padStart(2, '0')}${this.green.toString(16).padStart(2, '0')}${this.blue.toString(16).padStart(2, '0')}`;
374
- case 'hexa':
375
- return `#${this.red.toString(16).padStart(2, '0')}${this.green.toString(16).padStart(2, '0')}${this.blue.toString(16).padStart(2, '0')}${Math.round(this.alpha * 255).toString(16).padStart(2, '0')}`;
376
- case 'rgb':
377
- return `rgb(${this.red}, ${this.green}, ${this.blue})`;
378
- case 'rgba':
379
- case 'css':
380
- return this.alpha === 1
381
- ? `rgb(${this.red}, ${this.green}, ${this.blue})`
382
- : `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha})`;
383
- default:
384
- return this.toFormat(f as ColorFormat).toString(f);
385
- }
386
- }
387
-
388
- clone(): Color {
389
- return new RGBColorImpl(this.red, this.green, this.blue, this.alpha);
390
- }
391
-
392
- toHexInt(): number {
393
- return (this.red << 16) | (this.green << 8) | this.blue;
394
- }
395
-
396
- getChannelValue(channel: ColorChannel): number {
397
- switch (channel) {
398
- case 'red':
399
- return this.red;
400
- case 'green':
401
- return this.green;
402
- case 'blue':
403
- return this.blue;
404
- case 'alpha':
405
- return this.alpha;
406
- // Cross-color-space channels - convert to HSB
407
- case 'hue':
408
- case 'saturation':
409
- case 'brightness':
410
- return this.toFormat('hsb').getChannelValue(channel);
411
- case 'lightness':
412
- return this.toFormat('hsl').getChannelValue(channel);
413
- default:
414
- throw new Error(`Invalid channel: ${channel}`);
415
- }
416
- }
417
-
418
- withChannelValue(channel: ColorChannel, value: number): Color {
419
- switch (channel) {
420
- case 'red':
421
- return new RGBColorImpl(value, this.green, this.blue, this.alpha);
422
- case 'green':
423
- return new RGBColorImpl(this.red, value, this.blue, this.alpha);
424
- case 'blue':
425
- return new RGBColorImpl(this.red, this.green, value, this.alpha);
426
- case 'alpha':
427
- return new RGBColorImpl(this.red, this.green, this.blue, value);
428
- // Cross-color-space channels - convert, update, convert back
429
- case 'hue':
430
- case 'saturation':
431
- case 'brightness':
432
- return this.toFormat('hsb').withChannelValue(channel, value).toFormat('rgb');
433
- case 'lightness':
434
- return this.toFormat('hsl').withChannelValue(channel, value).toFormat('rgb');
435
- default:
436
- throw new Error(`Invalid channel: ${channel}`);
437
- }
438
- }
439
-
440
- getChannelRange(channel: ColorChannel): ColorChannelRange {
441
- switch (channel) {
442
- case 'red':
443
- case 'green':
444
- case 'blue':
445
- return RGB_CHANNEL_RANGE;
446
- case 'alpha':
447
- return ALPHA_CHANNEL_RANGE;
448
- case 'hue':
449
- return HUE_CHANNEL_RANGE;
450
- case 'saturation':
451
- case 'brightness':
452
- case 'lightness':
453
- return PERCENT_CHANNEL_RANGE;
454
- default:
455
- throw new Error(`Invalid channel: ${channel}`);
456
- }
457
- }
458
-
459
- getChannelName(channel: ColorChannel, _locale: string): string {
460
- return CHANNEL_NAMES[channel] || channel;
461
- }
462
-
463
- getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
464
- if (channel === 'alpha') {
465
- return { style: 'percent' };
466
- }
467
- return { maximumFractionDigits: 0 };
468
- }
469
-
470
- formatChannelValue(channel: ColorChannel, locale: string): string {
471
- const value = this.getChannelValue(channel);
472
- const options = this.getChannelFormatOptions(channel);
473
- return new Intl.NumberFormat(locale, options).format(value);
474
- }
475
-
476
- getColorSpace(): ColorSpace {
477
- return 'rgb';
478
- }
479
-
480
- getColorSpaceAxes(xyChannels?: { xChannel?: ColorChannel; yChannel?: ColorChannel }): ColorAxes {
481
- const xChannel = xyChannels?.xChannel ?? 'red';
482
- const yChannel = xyChannels?.yChannel ?? 'green';
483
- const channels: ColorChannel[] = ['red', 'green', 'blue'];
484
- const zChannel = channels.find((c) => c !== xChannel && c !== yChannel) ?? 'blue';
485
- return { xChannel, yChannel, zChannel };
486
- }
487
-
488
- getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
489
- return ['red', 'green', 'blue'];
490
- }
491
-
492
- getColorName(locale: string): string {
493
- return getColorNameFromRgb(this.red, this.green, this.blue, this.alpha);
494
- }
495
-
496
- getHueName(_locale: string): string {
497
- const { h } = rgbToHsl(this.red, this.green, this.blue);
498
- return getHueNameFromAngle(h);
499
- }
500
- }
501
-
502
- /**
503
- * HSL Color implementation.
504
- */
505
- class HSLColorImpl implements Color {
506
- private hue: number;
507
- private saturation: number;
508
- private lightness: number;
509
- private alpha: number;
510
-
511
- constructor(hue: number, saturation: number, lightness: number, alpha: number = 1) {
512
- this.hue = clamp(Math.round(hue) % 360, 0, 360);
513
- this.saturation = clamp(Math.round(saturation), 0, 100);
514
- this.lightness = clamp(Math.round(lightness), 0, 100);
515
- this.alpha = clamp(toFixed(alpha, 2), 0, 1);
516
- }
517
-
518
- toFormat(format: ColorFormat): Color {
519
- switch (format) {
520
- case 'hsl':
521
- case 'hsla':
522
- return this.clone();
523
- case 'hex':
524
- case 'hexa':
525
- case 'rgb':
526
- case 'rgba': {
527
- const { r, g, b } = hslToRgb(this.hue, this.saturation, this.lightness);
528
- return new RGBColorImpl(r, g, b, this.alpha);
529
- }
530
- case 'hsb':
531
- case 'hsba': {
532
- const { r, g, b } = hslToRgb(this.hue, this.saturation, this.lightness);
533
- const hsb = rgbToHsb(r, g, b);
534
- return new HSBColorImpl(hsb.h, hsb.s, hsb.b, this.alpha);
535
- }
536
- default:
537
- throw new Error(`Unsupported format: ${format}`);
538
- }
539
- }
540
-
541
- toString(format?: ColorFormat | 'css'): string {
542
- const f = format ?? 'css';
543
-
544
- switch (f) {
545
- case 'hsl':
546
- return `hsl(${this.hue}, ${this.saturation}%, ${this.lightness}%)`;
547
- case 'hsla':
548
- case 'css':
549
- return this.alpha === 1
550
- ? `hsl(${this.hue}, ${this.saturation}%, ${this.lightness}%)`
551
- : `hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha})`;
552
- default:
553
- return this.toFormat(f as ColorFormat).toString(f);
554
- }
555
- }
556
-
557
- clone(): Color {
558
- return new HSLColorImpl(this.hue, this.saturation, this.lightness, this.alpha);
559
- }
560
-
561
- toHexInt(): number {
562
- return this.toFormat('rgb').toHexInt();
563
- }
564
-
565
- getChannelValue(channel: ColorChannel): number {
566
- switch (channel) {
567
- case 'hue':
568
- return this.hue;
569
- case 'saturation':
570
- return this.saturation;
571
- case 'lightness':
572
- return this.lightness;
573
- case 'alpha':
574
- return this.alpha;
575
- // Cross-color-space channels
576
- case 'red':
577
- case 'green':
578
- case 'blue':
579
- return this.toFormat('rgb').getChannelValue(channel);
580
- case 'brightness':
581
- return this.toFormat('hsb').getChannelValue(channel);
582
- default:
583
- throw new Error(`Invalid channel: ${channel}`);
584
- }
585
- }
586
-
587
- withChannelValue(channel: ColorChannel, value: number): Color {
588
- switch (channel) {
589
- case 'hue':
590
- return new HSLColorImpl(value, this.saturation, this.lightness, this.alpha);
591
- case 'saturation':
592
- return new HSLColorImpl(this.hue, value, this.lightness, this.alpha);
593
- case 'lightness':
594
- return new HSLColorImpl(this.hue, this.saturation, value, this.alpha);
595
- case 'alpha':
596
- return new HSLColorImpl(this.hue, this.saturation, this.lightness, value);
597
- // Cross-color-space channels
598
- case 'red':
599
- case 'green':
600
- case 'blue':
601
- return this.toFormat('rgb').withChannelValue(channel, value).toFormat('hsl');
602
- case 'brightness':
603
- return this.toFormat('hsb').withChannelValue(channel, value).toFormat('hsl');
604
- default:
605
- throw new Error(`Invalid channel: ${channel}`);
606
- }
607
- }
608
-
609
- getChannelRange(channel: ColorChannel): ColorChannelRange {
610
- switch (channel) {
611
- case 'hue':
612
- return HUE_CHANNEL_RANGE;
613
- case 'saturation':
614
- case 'lightness':
615
- case 'brightness':
616
- return PERCENT_CHANNEL_RANGE;
617
- case 'alpha':
618
- return ALPHA_CHANNEL_RANGE;
619
- case 'red':
620
- case 'green':
621
- case 'blue':
622
- return RGB_CHANNEL_RANGE;
623
- default:
624
- throw new Error(`Invalid channel: ${channel}`);
625
- }
626
- }
627
-
628
- getChannelName(channel: ColorChannel, _locale: string): string {
629
- return CHANNEL_NAMES[channel] || channel;
630
- }
631
-
632
- getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
633
- if (channel === 'alpha') {
634
- return { style: 'percent' };
635
- }
636
- if (channel === 'hue') {
637
- return { maximumFractionDigits: 0 };
638
- }
639
- return { style: 'percent', maximumFractionDigits: 0 };
640
- }
641
-
642
- formatChannelValue(channel: ColorChannel, locale: string): string {
643
- const value = this.getChannelValue(channel);
644
- const options = this.getChannelFormatOptions(channel);
645
- if (channel === 'saturation' || channel === 'lightness') {
646
- return new Intl.NumberFormat(locale, options).format(value / 100);
647
- }
648
- return new Intl.NumberFormat(locale, options).format(value);
649
- }
650
-
651
- getColorSpace(): ColorSpace {
652
- return 'hsl';
653
- }
654
-
655
- getColorSpaceAxes(xyChannels?: { xChannel?: ColorChannel; yChannel?: ColorChannel }): ColorAxes {
656
- const xChannel = xyChannels?.xChannel ?? 'saturation';
657
- const yChannel = xyChannels?.yChannel ?? 'lightness';
658
- const channels: ColorChannel[] = ['hue', 'saturation', 'lightness'];
659
- const zChannel = channels.find((c) => c !== xChannel && c !== yChannel) ?? 'hue';
660
- return { xChannel, yChannel, zChannel };
661
- }
662
-
663
- getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
664
- return ['hue', 'saturation', 'lightness'];
665
- }
666
-
667
- getColorName(_locale: string): string {
668
- const { r, g, b } = hslToRgb(this.hue, this.saturation, this.lightness);
669
- return getColorNameFromRgb(r, g, b, this.alpha);
670
- }
671
-
672
- getHueName(_locale: string): string {
673
- return getHueNameFromAngle(this.hue);
674
- }
675
- }
676
-
677
- /**
678
- * HSB Color implementation.
679
- */
680
- class HSBColorImpl implements Color {
681
- private hue: number;
682
- private saturation: number;
683
- private brightness: number;
684
- private alpha: number;
685
-
686
- constructor(hue: number, saturation: number, brightness: number, alpha: number = 1) {
687
- this.hue = clamp(Math.round(hue) % 360, 0, 360);
688
- this.saturation = clamp(Math.round(saturation), 0, 100);
689
- this.brightness = clamp(Math.round(brightness), 0, 100);
690
- this.alpha = clamp(toFixed(alpha, 2), 0, 1);
691
- }
692
-
693
- toFormat(format: ColorFormat): Color {
694
- switch (format) {
695
- case 'hsb':
696
- case 'hsba':
697
- return this.clone();
698
- case 'hex':
699
- case 'hexa':
700
- case 'rgb':
701
- case 'rgba': {
702
- const { r, g, b } = hsbToRgb(this.hue, this.saturation, this.brightness);
703
- return new RGBColorImpl(r, g, b, this.alpha);
704
- }
705
- case 'hsl':
706
- case 'hsla': {
707
- const { r, g, b } = hsbToRgb(this.hue, this.saturation, this.brightness);
708
- const hsl = rgbToHsl(r, g, b);
709
- return new HSLColorImpl(hsl.h, hsl.s, hsl.l, this.alpha);
710
- }
711
- default:
712
- throw new Error(`Unsupported format: ${format}`);
713
- }
714
- }
715
-
716
- toString(format?: ColorFormat | 'css'): string {
717
- const f = format ?? 'css';
718
-
719
- switch (f) {
720
- case 'hsb':
721
- return `hsb(${this.hue}, ${this.saturation}%, ${this.brightness}%)`;
722
- case 'hsba':
723
- case 'css':
724
- // HSB is not a standard CSS format, convert to RGB
725
- return this.toFormat('rgba').toString('css');
726
- default:
727
- return this.toFormat(f as ColorFormat).toString(f);
728
- }
729
- }
730
-
731
- clone(): Color {
732
- return new HSBColorImpl(this.hue, this.saturation, this.brightness, this.alpha);
733
- }
734
-
735
- toHexInt(): number {
736
- return this.toFormat('rgb').toHexInt();
737
- }
738
-
739
- getChannelValue(channel: ColorChannel): number {
740
- switch (channel) {
741
- case 'hue':
742
- return this.hue;
743
- case 'saturation':
744
- return this.saturation;
745
- case 'brightness':
746
- return this.brightness;
747
- case 'alpha':
748
- return this.alpha;
749
- // Cross-color-space channels
750
- case 'red':
751
- case 'green':
752
- case 'blue':
753
- return this.toFormat('rgb').getChannelValue(channel);
754
- case 'lightness':
755
- return this.toFormat('hsl').getChannelValue(channel);
756
- default:
757
- throw new Error(`Invalid channel: ${channel}`);
758
- }
759
- }
760
-
761
- withChannelValue(channel: ColorChannel, value: number): Color {
762
- switch (channel) {
763
- case 'hue':
764
- return new HSBColorImpl(value, this.saturation, this.brightness, this.alpha);
765
- case 'saturation':
766
- return new HSBColorImpl(this.hue, value, this.brightness, this.alpha);
767
- case 'brightness':
768
- return new HSBColorImpl(this.hue, this.saturation, value, this.alpha);
769
- case 'alpha':
770
- return new HSBColorImpl(this.hue, this.saturation, this.brightness, value);
771
- // Cross-color-space channels
772
- case 'red':
773
- case 'green':
774
- case 'blue':
775
- return this.toFormat('rgb').withChannelValue(channel, value).toFormat('hsb');
776
- case 'lightness':
777
- return this.toFormat('hsl').withChannelValue(channel, value).toFormat('hsb');
778
- default:
779
- throw new Error(`Invalid channel: ${channel}`);
780
- }
781
- }
782
-
783
- getChannelRange(channel: ColorChannel): ColorChannelRange {
784
- switch (channel) {
785
- case 'hue':
786
- return HUE_CHANNEL_RANGE;
787
- case 'saturation':
788
- case 'brightness':
789
- case 'lightness':
790
- return PERCENT_CHANNEL_RANGE;
791
- case 'alpha':
792
- return ALPHA_CHANNEL_RANGE;
793
- case 'red':
794
- case 'green':
795
- case 'blue':
796
- return RGB_CHANNEL_RANGE;
797
- default:
798
- throw new Error(`Invalid channel: ${channel}`);
799
- }
800
- }
801
-
802
- getChannelName(channel: ColorChannel, _locale: string): string {
803
- return CHANNEL_NAMES[channel] || channel;
804
- }
805
-
806
- getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
807
- if (channel === 'alpha') {
808
- return { style: 'percent' };
809
- }
810
- if (channel === 'hue') {
811
- return { maximumFractionDigits: 0 };
812
- }
813
- return { style: 'percent', maximumFractionDigits: 0 };
814
- }
815
-
816
- formatChannelValue(channel: ColorChannel, locale: string): string {
817
- const value = this.getChannelValue(channel);
818
- const options = this.getChannelFormatOptions(channel);
819
- if (channel === 'saturation' || channel === 'brightness') {
820
- return new Intl.NumberFormat(locale, options).format(value / 100);
821
- }
822
- return new Intl.NumberFormat(locale, options).format(value);
823
- }
824
-
825
- getColorSpace(): ColorSpace {
826
- return 'hsb';
827
- }
828
-
829
- getColorSpaceAxes(xyChannels?: { xChannel?: ColorChannel; yChannel?: ColorChannel }): ColorAxes {
830
- const xChannel = xyChannels?.xChannel ?? 'saturation';
831
- const yChannel = xyChannels?.yChannel ?? 'brightness';
832
- const channels: ColorChannel[] = ['hue', 'saturation', 'brightness'];
833
- const zChannel = channels.find((c) => c !== xChannel && c !== yChannel) ?? 'hue';
834
- return { xChannel, yChannel, zChannel };
835
- }
836
-
837
- getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
838
- return ['hue', 'saturation', 'brightness'];
839
- }
840
-
841
- getColorName(_locale: string): string {
842
- const { r, g, b } = hsbToRgb(this.hue, this.saturation, this.brightness);
843
- return getColorNameFromRgb(r, g, b, this.alpha);
844
- }
845
-
846
- getHueName(_locale: string): string {
847
- return getHueNameFromAngle(this.hue);
848
- }
849
- }
850
-
851
- /**
852
- * Parse a color string into a Color object.
853
- */
854
- export function parseColor(value: string): Color {
855
- const trimmed = value.trim().toLowerCase();
856
-
857
- // Hex format
858
- if (trimmed.startsWith('#')) {
859
- const hex = trimmed.slice(1);
860
- if (hex.length === 3) {
861
- const r = parseInt(hex[0] + hex[0], 16);
862
- const g = parseInt(hex[1] + hex[1], 16);
863
- const b = parseInt(hex[2] + hex[2], 16);
864
- return new RGBColorImpl(r, g, b);
865
- }
866
- if (hex.length === 4) {
867
- const r = parseInt(hex[0] + hex[0], 16);
868
- const g = parseInt(hex[1] + hex[1], 16);
869
- const b = parseInt(hex[2] + hex[2], 16);
870
- const a = parseInt(hex[3] + hex[3], 16) / 255;
871
- return new RGBColorImpl(r, g, b, a);
872
- }
873
- if (hex.length === 6) {
874
- const r = parseInt(hex.slice(0, 2), 16);
875
- const g = parseInt(hex.slice(2, 4), 16);
876
- const b = parseInt(hex.slice(4, 6), 16);
877
- return new RGBColorImpl(r, g, b);
878
- }
879
- if (hex.length === 8) {
880
- const r = parseInt(hex.slice(0, 2), 16);
881
- const g = parseInt(hex.slice(2, 4), 16);
882
- const b = parseInt(hex.slice(4, 6), 16);
883
- const a = parseInt(hex.slice(6, 8), 16) / 255;
884
- return new RGBColorImpl(r, g, b, a);
885
- }
886
- throw new Error(`Invalid hex color: ${value}`);
887
- }
888
-
889
- // RGB/RGBA format
890
- const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/);
891
- if (rgbMatch) {
892
- const r = parseInt(rgbMatch[1], 10);
893
- const g = parseInt(rgbMatch[2], 10);
894
- const b = parseInt(rgbMatch[3], 10);
895
- const a = rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1;
896
- return new RGBColorImpl(r, g, b, a);
897
- }
898
-
899
- // HSL/HSLA format
900
- const hslMatch = trimmed.match(/^hsla?\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*([\d.]+))?\s*\)$/);
901
- if (hslMatch) {
902
- const h = parseInt(hslMatch[1], 10);
903
- const s = parseInt(hslMatch[2], 10);
904
- const l = parseInt(hslMatch[3], 10);
905
- const a = hslMatch[4] !== undefined ? parseFloat(hslMatch[4]) : 1;
906
- return new HSLColorImpl(h, s, l, a);
907
- }
908
-
909
- // HSB/HSBA format
910
- const hsbMatch = trimmed.match(/^hsba?\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*([\d.]+))?\s*\)$/);
911
- if (hsbMatch) {
912
- const h = parseInt(hsbMatch[1], 10);
913
- const s = parseInt(hsbMatch[2], 10);
914
- const b = parseInt(hsbMatch[3], 10);
915
- const a = hsbMatch[4] !== undefined ? parseFloat(hsbMatch[4]) : 1;
916
- return new HSBColorImpl(h, s, b, a);
917
- }
918
-
919
- throw new Error(`Invalid color format: ${value}`);
920
- }
921
-
922
- /**
923
- * Create an RGB color.
924
- */
925
- export function createRGBColor(red: number, green: number, blue: number, alpha: number = 1): Color {
926
- return new RGBColorImpl(red, green, blue, alpha);
927
- }
928
-
929
- /**
930
- * Create an HSL color.
931
- */
932
- export function createHSLColor(hue: number, saturation: number, lightness: number, alpha: number = 1): Color {
933
- return new HSLColorImpl(hue, saturation, lightness, alpha);
934
- }
935
-
936
- /**
937
- * Create an HSB color.
938
- */
939
- export function createHSBColor(hue: number, saturation: number, brightness: number, alpha: number = 1): Color {
940
- return new HSBColorImpl(hue, saturation, brightness, alpha);
941
- }
942
-
943
- /**
944
- * Normalize a color value (string or Color) to a Color object.
945
- */
946
- export function normalizeColor(value: string | Color): Color {
947
- if (typeof value === 'string') {
948
- return parseColor(value);
949
- }
950
- return value;
951
- }
1
+ /**
2
+ * Color class implementation.
3
+ * Based on @react-stately/color.
4
+ *
5
+ * Provides color manipulation, conversion, and formatting.
6
+ */
7
+
8
+ import type {
9
+ Color,
10
+ ColorFormat,
11
+ ColorSpace,
12
+ ColorChannel,
13
+ ColorChannelRange,
14
+ ColorAxes,
15
+ RGBColor,
16
+ HSLColor,
17
+ HSBColor,
18
+ } from "./types";
19
+
20
+ // Channel ranges
21
+ const RGB_CHANNEL_RANGE: ColorChannelRange = {
22
+ minValue: 0,
23
+ maxValue: 255,
24
+ step: 1,
25
+ pageSize: 17,
26
+ };
27
+
28
+ const ALPHA_CHANNEL_RANGE: ColorChannelRange = {
29
+ minValue: 0,
30
+ maxValue: 1,
31
+ step: 0.01,
32
+ pageSize: 0.1,
33
+ };
34
+
35
+ const HUE_CHANNEL_RANGE: ColorChannelRange = {
36
+ minValue: 0,
37
+ maxValue: 360,
38
+ step: 1,
39
+ pageSize: 15,
40
+ };
41
+
42
+ const PERCENT_CHANNEL_RANGE: ColorChannelRange = {
43
+ minValue: 0,
44
+ maxValue: 100,
45
+ step: 1,
46
+ pageSize: 10,
47
+ };
48
+
49
+ // Channel names (English only for now)
50
+ const CHANNEL_NAMES: Record<ColorChannel, string> = {
51
+ hue: "Hue",
52
+ saturation: "Saturation",
53
+ brightness: "Brightness",
54
+ lightness: "Lightness",
55
+ red: "Red",
56
+ green: "Green",
57
+ blue: "Blue",
58
+ alpha: "Alpha",
59
+ };
60
+
61
+ // React Stately uses OKLCH for color names so that lightness is perceptually
62
+ // consistent across hues.
63
+ const ORANGE_LIGHTNESS_THRESHOLD = 0.68;
64
+ const YELLOW_GREEN_LIGHTNESS_THRESHOLD = 0.85;
65
+ const MAX_DARK_LIGHTNESS = 0.55;
66
+ const GRAY_THRESHOLD = 0.001;
67
+ const OKLCH_HUES: Array<[number, string]> = [
68
+ [0, "pink"],
69
+ [15, "red"],
70
+ [48, "orange"],
71
+ [94, "yellow"],
72
+ [135, "green"],
73
+ [175, "cyan"],
74
+ [264, "blue"],
75
+ [284, "purple"],
76
+ [320, "magenta"],
77
+ [349, "pink"],
78
+ ];
79
+
80
+ /**
81
+ * Clamp a value to a range.
82
+ */
83
+ function clamp(value: number, min: number, max: number): number {
84
+ return Math.min(Math.max(value, min), max);
85
+ }
86
+
87
+ /**
88
+ * Round to fixed decimal places.
89
+ */
90
+ function toFixed(value: number, decimals: number): number {
91
+ return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
92
+ }
93
+
94
+ /**
95
+ * Convert RGB to HSL.
96
+ */
97
+ function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
98
+ r /= 255;
99
+ g /= 255;
100
+ b /= 255;
101
+
102
+ const max = Math.max(r, g, b);
103
+ const min = Math.min(r, g, b);
104
+ let h = 0;
105
+ let s = 0;
106
+ const l = (max + min) / 2;
107
+
108
+ if (max !== min) {
109
+ const d = max - min;
110
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
111
+
112
+ switch (max) {
113
+ case r:
114
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
115
+ break;
116
+ case g:
117
+ h = ((b - r) / d + 2) / 6;
118
+ break;
119
+ case b:
120
+ h = ((r - g) / d + 4) / 6;
121
+ break;
122
+ }
123
+ }
124
+
125
+ return {
126
+ h: Math.round(h * 360),
127
+ s: Math.round(s * 100),
128
+ l: Math.round(l * 100),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Convert HSL to RGB.
134
+ */
135
+ function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
136
+ h /= 360;
137
+ s /= 100;
138
+ l /= 100;
139
+
140
+ let r: number, g: number, b: number;
141
+
142
+ if (s === 0) {
143
+ r = g = b = l;
144
+ } else {
145
+ const hue2rgb = (p: number, q: number, t: number) => {
146
+ if (t < 0) t += 1;
147
+ if (t > 1) t -= 1;
148
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
149
+ if (t < 1 / 2) return q;
150
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
151
+ return p;
152
+ };
153
+
154
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
155
+ const p = 2 * l - q;
156
+ r = hue2rgb(p, q, h + 1 / 3);
157
+ g = hue2rgb(p, q, h);
158
+ b = hue2rgb(p, q, h - 1 / 3);
159
+ }
160
+
161
+ return {
162
+ r: Math.round(r * 255),
163
+ g: Math.round(g * 255),
164
+ b: Math.round(b * 255),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Convert RGB to HSB.
170
+ */
171
+ function rgbToHsb(r: number, g: number, b: number): { h: number; s: number; b: number } {
172
+ r /= 255;
173
+ g /= 255;
174
+ b /= 255;
175
+
176
+ const max = Math.max(r, g, b);
177
+ const min = Math.min(r, g, b);
178
+ let h = 0;
179
+ const v = max;
180
+ const d = max - min;
181
+ const s = max === 0 ? 0 : d / max;
182
+
183
+ if (max !== min) {
184
+ switch (max) {
185
+ case r:
186
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
187
+ break;
188
+ case g:
189
+ h = ((b - r) / d + 2) / 6;
190
+ break;
191
+ case b:
192
+ h = ((r - g) / d + 4) / 6;
193
+ break;
194
+ }
195
+ }
196
+
197
+ return {
198
+ h: Math.round(h * 360),
199
+ s: Math.round(s * 100),
200
+ b: Math.round(v * 100),
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Convert HSB to RGB.
206
+ */
207
+ function hsbToRgb(h: number, s: number, b: number): { r: number; g: number; b: number } {
208
+ h /= 360;
209
+ s /= 100;
210
+ b /= 100;
211
+
212
+ let r: number, g: number, bl: number;
213
+
214
+ const i = Math.floor(h * 6);
215
+ const f = h * 6 - i;
216
+ const p = b * (1 - s);
217
+ const q = b * (1 - f * s);
218
+ const t = b * (1 - (1 - f) * s);
219
+
220
+ switch (i % 6) {
221
+ case 0:
222
+ r = b;
223
+ g = t;
224
+ bl = p;
225
+ break;
226
+ case 1:
227
+ r = q;
228
+ g = b;
229
+ bl = p;
230
+ break;
231
+ case 2:
232
+ r = p;
233
+ g = b;
234
+ bl = t;
235
+ break;
236
+ case 3:
237
+ r = p;
238
+ g = q;
239
+ bl = b;
240
+ break;
241
+ case 4:
242
+ r = t;
243
+ g = p;
244
+ bl = b;
245
+ break;
246
+ default:
247
+ r = b;
248
+ g = p;
249
+ bl = q;
250
+ break;
251
+ }
252
+
253
+ return {
254
+ r: Math.round(r * 255),
255
+ g: Math.round(g * 255),
256
+ b: Math.round(bl * 255),
257
+ };
258
+ }
259
+
260
+ function getOklchHueName(l: number, c: number, h: number): [string, number] {
261
+ if (c < GRAY_THRESHOLD) {
262
+ return ["gray", l];
263
+ }
264
+
265
+ for (let i = 0; i < OKLCH_HUES.length; i += 1) {
266
+ let [hue, hueName] = OKLCH_HUES[i];
267
+ const [nextHue, nextHueName] = OKLCH_HUES[i + 1] || [360, "pink"];
268
+ if (h >= hue && h < nextHue) {
269
+ if (hueName === "orange") {
270
+ if (l < ORANGE_LIGHTNESS_THRESHOLD) {
271
+ hueName = "brown";
272
+ } else {
273
+ l = l - ORANGE_LIGHTNESS_THRESHOLD + MAX_DARK_LIGHTNESS;
274
+ }
275
+ }
276
+
277
+ if (h > hue + (nextHue - hue) / 2 && hueName !== nextHueName) {
278
+ hueName = `${hueName} ${nextHueName}`;
279
+ } else if (hueName === "yellow" && l < YELLOW_GREEN_LIGHTNESS_THRESHOLD) {
280
+ hueName = "yellow green";
281
+ }
282
+
283
+ return [hueName.toLocaleLowerCase(), l];
284
+ }
285
+ }
286
+
287
+ return ["pink", l];
288
+ }
289
+
290
+ function getColorNameFromColor(color: Color): string {
291
+ let [l, c, h] = toOKLCH(color);
292
+ const alpha = color.getChannelValue("alpha");
293
+
294
+ if (l > 0.999) {
295
+ return alpha < 1 ? `white ${Math.round((1 - alpha) * 100)}% transparent` : "white";
296
+ }
297
+
298
+ if (l < 0.001) {
299
+ return alpha < 1 ? `black ${Math.round((1 - alpha) * 100)}% transparent` : "black";
300
+ }
301
+
302
+ let hue: string;
303
+ [hue, l] = getOklchHueName(l, c, h);
304
+
305
+ let chroma = "";
306
+ if (c <= 0.1 && c >= GRAY_THRESHOLD) {
307
+ chroma = l >= 0.7 ? "pale" : "grayish";
308
+ } else if (c >= 0.15) {
309
+ chroma = "vibrant";
310
+ }
311
+
312
+ let lightness = "";
313
+ if (l < 0.3) {
314
+ lightness = "very dark";
315
+ } else if (l < MAX_DARK_LIGHTNESS) {
316
+ lightness = "dark";
317
+ } else if (l < 0.7) {
318
+ lightness = "";
319
+ } else if (l < 0.85) {
320
+ lightness = "light";
321
+ } else {
322
+ lightness = "very light";
323
+ }
324
+
325
+ const name = [lightness, chroma, hue].filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
326
+ if (alpha < 1) {
327
+ return `${Math.round((1 - alpha) * 100)}% transparent ${name}`.trim();
328
+ }
329
+
330
+ return name;
331
+ }
332
+
333
+ function getHueNameFromColor(color: Color): string {
334
+ const [l, c, h] = toOKLCH(color);
335
+ const [name] = getOklchHueName(l, c, h);
336
+ return name;
337
+ }
338
+
339
+ function toOKLCH(color: Color): [number, number, number] {
340
+ const rgb = color.toFormat("rgb");
341
+ let red = rgb.getChannelValue("red") / 255;
342
+ let green = rgb.getChannelValue("green") / 255;
343
+ let blue = rgb.getChannelValue("blue") / 255;
344
+ [red, green, blue] = linSRGB(red, green, blue);
345
+ const [x, y, z] = linSRGBToXYZ(red, green, blue);
346
+ const [l, a, b] = xyzToOKLab(x, y, z);
347
+ return okLabToOKLCH(l, a, b);
348
+ }
349
+
350
+ function okLabToOKLCH(l: number, a: number, b: number): [number, number, number] {
351
+ const hue = (Math.atan2(b, a) * 180) / Math.PI;
352
+ return [l, Math.sqrt(a ** 2 + b ** 2), hue >= 0 ? hue : hue + 360];
353
+ }
354
+
355
+ function linSRGB(r: number, g: number, b: number): [number, number, number] {
356
+ return [linSRGBComponent(r), linSRGBComponent(g), linSRGBComponent(b)];
357
+ }
358
+
359
+ function linSRGBComponent(value: number) {
360
+ const sign = value < 0 ? -1 : 1;
361
+ const abs = Math.abs(value);
362
+
363
+ if (abs <= 0.04045) {
364
+ return value / 12.92;
365
+ }
366
+
367
+ return sign * Math.pow((abs + 0.055) / 1.055, 2.4);
368
+ }
369
+
370
+ function linSRGBToXYZ(r: number, g: number, b: number): [number, number, number] {
371
+ const matrix = [
372
+ 506752 / 1228815,
373
+ 87881 / 245763,
374
+ 12673 / 70218,
375
+ 87098 / 409605,
376
+ 175762 / 245763,
377
+ 12673 / 175545,
378
+ 7918 / 409605,
379
+ 87881 / 737289,
380
+ 1001167 / 1053270,
381
+ ];
382
+ return multiplyMatrix(matrix, r, g, b);
383
+ }
384
+
385
+ function xyzToOKLab(x: number, y: number, z: number): [number, number, number] {
386
+ const xyzToLMS = [
387
+ 0.819022437996703, 0.3619062600528904, -0.1288737815209879, 0.0329836539323885,
388
+ 0.9292868615863434, 0.0361446663506424, 0.0481771893596242, 0.2642395317527308,
389
+ 0.6335478284694309,
390
+ ];
391
+ const lmsToOKLab = [
392
+ 0.210454268309314, 0.7936177747023054, -0.0040720430116193, 1.9779985324311684,
393
+ -2.4285922420485799, 0.450593709617411, 0.0259040424655478, 0.7827717124575296,
394
+ -0.8086757549230774,
395
+ ];
396
+
397
+ const [a, b, c] = multiplyMatrix(xyzToLMS, x, y, z);
398
+ return multiplyMatrix(lmsToOKLab, Math.cbrt(a), Math.cbrt(b), Math.cbrt(c));
399
+ }
400
+
401
+ function multiplyMatrix(
402
+ matrix: number[],
403
+ x: number,
404
+ y: number,
405
+ z: number,
406
+ ): [number, number, number] {
407
+ const a = matrix[0] * x + matrix[1] * y + matrix[2] * z;
408
+ const b = matrix[3] * x + matrix[4] * y + matrix[5] * z;
409
+ const c = matrix[6] * x + matrix[7] * y + matrix[8] * z;
410
+ return [a, b, c];
411
+ }
412
+
413
+ /**
414
+ * RGB Color implementation.
415
+ */
416
+ class RGBColorImpl implements Color {
417
+ private red: number;
418
+ private green: number;
419
+ private blue: number;
420
+ private alpha: number;
421
+
422
+ constructor(red: number, green: number, blue: number, alpha: number = 1) {
423
+ this.red = clamp(Math.round(red), 0, 255);
424
+ this.green = clamp(Math.round(green), 0, 255);
425
+ this.blue = clamp(Math.round(blue), 0, 255);
426
+ this.alpha = clamp(toFixed(alpha, 2), 0, 1);
427
+ }
428
+
429
+ toFormat(format: ColorFormat): Color {
430
+ switch (format) {
431
+ case "hex":
432
+ case "hexa":
433
+ case "rgb":
434
+ case "rgba":
435
+ return this.clone();
436
+ case "hsl":
437
+ case "hsla": {
438
+ const { h, s, l } = rgbToHsl(this.red, this.green, this.blue);
439
+ return new HSLColorImpl(h, s, l, this.alpha);
440
+ }
441
+ case "hsb":
442
+ case "hsba": {
443
+ const { h, s, b } = rgbToHsb(this.red, this.green, this.blue);
444
+ return new HSBColorImpl(h, s, b, this.alpha);
445
+ }
446
+ default:
447
+ throw new Error(`Unsupported format: ${format}`);
448
+ }
449
+ }
450
+
451
+ toString(format?: ColorFormat | "css"): string {
452
+ const f = format ?? "css";
453
+
454
+ switch (f) {
455
+ case "hex":
456
+ return `#${this.red.toString(16).padStart(2, "0")}${this.green.toString(16).padStart(2, "0")}${this.blue.toString(16).padStart(2, "0")}`;
457
+ case "hexa":
458
+ return `#${this.red.toString(16).padStart(2, "0")}${this.green.toString(16).padStart(2, "0")}${this.blue.toString(16).padStart(2, "0")}${Math.round(
459
+ this.alpha * 255,
460
+ )
461
+ .toString(16)
462
+ .padStart(2, "0")}`;
463
+ case "rgb":
464
+ return `rgb(${this.red}, ${this.green}, ${this.blue})`;
465
+ case "rgba":
466
+ case "css":
467
+ return this.alpha === 1
468
+ ? `rgb(${this.red}, ${this.green}, ${this.blue})`
469
+ : `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha})`;
470
+ default:
471
+ return this.toFormat(f as ColorFormat).toString(f);
472
+ }
473
+ }
474
+
475
+ clone(): Color {
476
+ return new RGBColorImpl(this.red, this.green, this.blue, this.alpha);
477
+ }
478
+
479
+ toHexInt(): number {
480
+ return (this.red << 16) | (this.green << 8) | this.blue;
481
+ }
482
+
483
+ getChannelValue(channel: ColorChannel): number {
484
+ switch (channel) {
485
+ case "red":
486
+ return this.red;
487
+ case "green":
488
+ return this.green;
489
+ case "blue":
490
+ return this.blue;
491
+ case "alpha":
492
+ return this.alpha;
493
+ // Cross-color-space channels - convert to HSB
494
+ case "hue":
495
+ case "saturation":
496
+ case "brightness":
497
+ return this.toFormat("hsb").getChannelValue(channel);
498
+ case "lightness":
499
+ return this.toFormat("hsl").getChannelValue(channel);
500
+ default:
501
+ throw new Error(`Invalid channel: ${channel}`);
502
+ }
503
+ }
504
+
505
+ withChannelValue(channel: ColorChannel, value: number): Color {
506
+ switch (channel) {
507
+ case "red":
508
+ return new RGBColorImpl(value, this.green, this.blue, this.alpha);
509
+ case "green":
510
+ return new RGBColorImpl(this.red, value, this.blue, this.alpha);
511
+ case "blue":
512
+ return new RGBColorImpl(this.red, this.green, value, this.alpha);
513
+ case "alpha":
514
+ return new RGBColorImpl(this.red, this.green, this.blue, value);
515
+ // Cross-color-space channels - convert, update, convert back
516
+ case "hue":
517
+ case "saturation":
518
+ case "brightness":
519
+ return this.toFormat("hsb").withChannelValue(channel, value).toFormat("rgb");
520
+ case "lightness":
521
+ return this.toFormat("hsl").withChannelValue(channel, value).toFormat("rgb");
522
+ default:
523
+ throw new Error(`Invalid channel: ${channel}`);
524
+ }
525
+ }
526
+
527
+ getChannelRange(channel: ColorChannel): ColorChannelRange {
528
+ switch (channel) {
529
+ case "red":
530
+ case "green":
531
+ case "blue":
532
+ return RGB_CHANNEL_RANGE;
533
+ case "alpha":
534
+ return ALPHA_CHANNEL_RANGE;
535
+ case "hue":
536
+ return HUE_CHANNEL_RANGE;
537
+ case "saturation":
538
+ case "brightness":
539
+ case "lightness":
540
+ return PERCENT_CHANNEL_RANGE;
541
+ default:
542
+ throw new Error(`Invalid channel: ${channel}`);
543
+ }
544
+ }
545
+
546
+ getChannelName(channel: ColorChannel, _locale: string): string {
547
+ return CHANNEL_NAMES[channel] || channel;
548
+ }
549
+
550
+ getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
551
+ if (channel === "alpha") {
552
+ return { style: "percent" };
553
+ }
554
+ return { maximumFractionDigits: 0 };
555
+ }
556
+
557
+ formatChannelValue(channel: ColorChannel, locale: string): string {
558
+ const value = this.getChannelValue(channel);
559
+ const options = this.getChannelFormatOptions(channel);
560
+ return new Intl.NumberFormat(locale, options).format(value);
561
+ }
562
+
563
+ getColorSpace(): ColorSpace {
564
+ return "rgb";
565
+ }
566
+
567
+ getColorSpaceAxes(xyChannels?: { xChannel?: ColorChannel; yChannel?: ColorChannel }): ColorAxes {
568
+ const xChannel = xyChannels?.xChannel ?? "red";
569
+ const yChannel = xyChannels?.yChannel ?? "green";
570
+ const channels: ColorChannel[] = ["red", "green", "blue"];
571
+ const zChannel = channels.find((c) => c !== xChannel && c !== yChannel) ?? "blue";
572
+ return { xChannel, yChannel, zChannel };
573
+ }
574
+
575
+ getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
576
+ return ["red", "green", "blue"];
577
+ }
578
+
579
+ getColorName(_locale: string): string {
580
+ return getColorNameFromColor(this);
581
+ }
582
+
583
+ getHueName(_locale: string): string {
584
+ return getHueNameFromColor(this);
585
+ }
586
+ }
587
+
588
+ /**
589
+ * HSL Color implementation.
590
+ */
591
+ class HSLColorImpl implements Color {
592
+ private hue: number;
593
+ private saturation: number;
594
+ private lightness: number;
595
+ private alpha: number;
596
+
597
+ constructor(hue: number, saturation: number, lightness: number, alpha: number = 1) {
598
+ this.hue = clamp(Math.round(hue) % 360, 0, 360);
599
+ this.saturation = clamp(Math.round(saturation), 0, 100);
600
+ this.lightness = clamp(Math.round(lightness), 0, 100);
601
+ this.alpha = clamp(toFixed(alpha, 2), 0, 1);
602
+ }
603
+
604
+ toFormat(format: ColorFormat): Color {
605
+ switch (format) {
606
+ case "hsl":
607
+ case "hsla":
608
+ return this.clone();
609
+ case "hex":
610
+ case "hexa":
611
+ case "rgb":
612
+ case "rgba": {
613
+ const { r, g, b } = hslToRgb(this.hue, this.saturation, this.lightness);
614
+ return new RGBColorImpl(r, g, b, this.alpha);
615
+ }
616
+ case "hsb":
617
+ case "hsba": {
618
+ const { r, g, b } = hslToRgb(this.hue, this.saturation, this.lightness);
619
+ const hsb = rgbToHsb(r, g, b);
620
+ return new HSBColorImpl(hsb.h, hsb.s, hsb.b, this.alpha);
621
+ }
622
+ default:
623
+ throw new Error(`Unsupported format: ${format}`);
624
+ }
625
+ }
626
+
627
+ toString(format?: ColorFormat | "css"): string {
628
+ const f = format ?? "css";
629
+
630
+ switch (f) {
631
+ case "hsl":
632
+ return `hsl(${this.hue}, ${this.saturation}%, ${this.lightness}%)`;
633
+ case "hsla":
634
+ case "css":
635
+ return this.alpha === 1
636
+ ? `hsl(${this.hue}, ${this.saturation}%, ${this.lightness}%)`
637
+ : `hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha})`;
638
+ default:
639
+ return this.toFormat(f as ColorFormat).toString(f);
640
+ }
641
+ }
642
+
643
+ clone(): Color {
644
+ return new HSLColorImpl(this.hue, this.saturation, this.lightness, this.alpha);
645
+ }
646
+
647
+ toHexInt(): number {
648
+ return this.toFormat("rgb").toHexInt();
649
+ }
650
+
651
+ getChannelValue(channel: ColorChannel): number {
652
+ switch (channel) {
653
+ case "hue":
654
+ return this.hue;
655
+ case "saturation":
656
+ return this.saturation;
657
+ case "lightness":
658
+ return this.lightness;
659
+ case "alpha":
660
+ return this.alpha;
661
+ // Cross-color-space channels
662
+ case "red":
663
+ case "green":
664
+ case "blue":
665
+ return this.toFormat("rgb").getChannelValue(channel);
666
+ case "brightness":
667
+ return this.toFormat("hsb").getChannelValue(channel);
668
+ default:
669
+ throw new Error(`Invalid channel: ${channel}`);
670
+ }
671
+ }
672
+
673
+ withChannelValue(channel: ColorChannel, value: number): Color {
674
+ switch (channel) {
675
+ case "hue":
676
+ return new HSLColorImpl(value, this.saturation, this.lightness, this.alpha);
677
+ case "saturation":
678
+ return new HSLColorImpl(this.hue, value, this.lightness, this.alpha);
679
+ case "lightness":
680
+ return new HSLColorImpl(this.hue, this.saturation, value, this.alpha);
681
+ case "alpha":
682
+ return new HSLColorImpl(this.hue, this.saturation, this.lightness, value);
683
+ // Cross-color-space channels
684
+ case "red":
685
+ case "green":
686
+ case "blue":
687
+ return this.toFormat("rgb").withChannelValue(channel, value).toFormat("hsl");
688
+ case "brightness":
689
+ return this.toFormat("hsb").withChannelValue(channel, value).toFormat("hsl");
690
+ default:
691
+ throw new Error(`Invalid channel: ${channel}`);
692
+ }
693
+ }
694
+
695
+ getChannelRange(channel: ColorChannel): ColorChannelRange {
696
+ switch (channel) {
697
+ case "hue":
698
+ return HUE_CHANNEL_RANGE;
699
+ case "saturation":
700
+ case "lightness":
701
+ case "brightness":
702
+ return PERCENT_CHANNEL_RANGE;
703
+ case "alpha":
704
+ return ALPHA_CHANNEL_RANGE;
705
+ case "red":
706
+ case "green":
707
+ case "blue":
708
+ return RGB_CHANNEL_RANGE;
709
+ default:
710
+ throw new Error(`Invalid channel: ${channel}`);
711
+ }
712
+ }
713
+
714
+ getChannelName(channel: ColorChannel, _locale: string): string {
715
+ return CHANNEL_NAMES[channel] || channel;
716
+ }
717
+
718
+ getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
719
+ if (channel === "alpha") {
720
+ return { style: "percent" };
721
+ }
722
+ if (channel === "hue") {
723
+ return { style: "unit", unit: "degree", unitDisplay: "narrow", maximumFractionDigits: 0 };
724
+ }
725
+ return { style: "percent", maximumFractionDigits: 0 };
726
+ }
727
+
728
+ formatChannelValue(channel: ColorChannel, locale: string): string {
729
+ const value = this.getChannelValue(channel);
730
+ const options = this.getChannelFormatOptions(channel);
731
+ if (channel === "saturation" || channel === "lightness") {
732
+ return new Intl.NumberFormat(locale, options).format(value / 100);
733
+ }
734
+ return new Intl.NumberFormat(locale, options).format(value);
735
+ }
736
+
737
+ getColorSpace(): ColorSpace {
738
+ return "hsl";
739
+ }
740
+
741
+ getColorSpaceAxes(xyChannels?: { xChannel?: ColorChannel; yChannel?: ColorChannel }): ColorAxes {
742
+ const xChannel = xyChannels?.xChannel ?? "saturation";
743
+ const yChannel = xyChannels?.yChannel ?? "lightness";
744
+ const channels: ColorChannel[] = ["hue", "saturation", "lightness"];
745
+ const zChannel = channels.find((c) => c !== xChannel && c !== yChannel) ?? "hue";
746
+ return { xChannel, yChannel, zChannel };
747
+ }
748
+
749
+ getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
750
+ return ["hue", "saturation", "lightness"];
751
+ }
752
+
753
+ getColorName(_locale: string): string {
754
+ return getColorNameFromColor(this);
755
+ }
756
+
757
+ getHueName(_locale: string): string {
758
+ return getHueNameFromColor(this);
759
+ }
760
+ }
761
+
762
+ /**
763
+ * HSB Color implementation.
764
+ */
765
+ class HSBColorImpl implements Color {
766
+ private hue: number;
767
+ private saturation: number;
768
+ private brightness: number;
769
+ private alpha: number;
770
+
771
+ constructor(hue: number, saturation: number, brightness: number, alpha: number = 1) {
772
+ this.hue = clamp(Math.round(hue) % 360, 0, 360);
773
+ this.saturation = clamp(Math.round(saturation), 0, 100);
774
+ this.brightness = clamp(Math.round(brightness), 0, 100);
775
+ this.alpha = clamp(toFixed(alpha, 2), 0, 1);
776
+ }
777
+
778
+ toFormat(format: ColorFormat): Color {
779
+ switch (format) {
780
+ case "hsb":
781
+ case "hsba":
782
+ return this.clone();
783
+ case "hex":
784
+ case "hexa":
785
+ case "rgb":
786
+ case "rgba": {
787
+ const { r, g, b } = hsbToRgb(this.hue, this.saturation, this.brightness);
788
+ return new RGBColorImpl(r, g, b, this.alpha);
789
+ }
790
+ case "hsl":
791
+ case "hsla": {
792
+ const { r, g, b } = hsbToRgb(this.hue, this.saturation, this.brightness);
793
+ const hsl = rgbToHsl(r, g, b);
794
+ return new HSLColorImpl(hsl.h, hsl.s, hsl.l, this.alpha);
795
+ }
796
+ default:
797
+ throw new Error(`Unsupported format: ${format}`);
798
+ }
799
+ }
800
+
801
+ toString(format?: ColorFormat | "css"): string {
802
+ const f = format ?? "css";
803
+
804
+ switch (f) {
805
+ case "hsb":
806
+ return `hsb(${this.hue}, ${this.saturation}%, ${this.brightness}%)`;
807
+ case "hsba":
808
+ case "css":
809
+ // HSB is not a standard CSS format, convert to RGB
810
+ return this.toFormat("rgba").toString("css");
811
+ default:
812
+ return this.toFormat(f as ColorFormat).toString(f);
813
+ }
814
+ }
815
+
816
+ clone(): Color {
817
+ return new HSBColorImpl(this.hue, this.saturation, this.brightness, this.alpha);
818
+ }
819
+
820
+ toHexInt(): number {
821
+ return this.toFormat("rgb").toHexInt();
822
+ }
823
+
824
+ getChannelValue(channel: ColorChannel): number {
825
+ switch (channel) {
826
+ case "hue":
827
+ return this.hue;
828
+ case "saturation":
829
+ return this.saturation;
830
+ case "brightness":
831
+ return this.brightness;
832
+ case "alpha":
833
+ return this.alpha;
834
+ // Cross-color-space channels
835
+ case "red":
836
+ case "green":
837
+ case "blue":
838
+ return this.toFormat("rgb").getChannelValue(channel);
839
+ case "lightness":
840
+ return this.toFormat("hsl").getChannelValue(channel);
841
+ default:
842
+ throw new Error(`Invalid channel: ${channel}`);
843
+ }
844
+ }
845
+
846
+ withChannelValue(channel: ColorChannel, value: number): Color {
847
+ switch (channel) {
848
+ case "hue":
849
+ return new HSBColorImpl(value, this.saturation, this.brightness, this.alpha);
850
+ case "saturation":
851
+ return new HSBColorImpl(this.hue, value, this.brightness, this.alpha);
852
+ case "brightness":
853
+ return new HSBColorImpl(this.hue, this.saturation, value, this.alpha);
854
+ case "alpha":
855
+ return new HSBColorImpl(this.hue, this.saturation, this.brightness, value);
856
+ // Cross-color-space channels
857
+ case "red":
858
+ case "green":
859
+ case "blue":
860
+ return this.toFormat("rgb").withChannelValue(channel, value).toFormat("hsb");
861
+ case "lightness":
862
+ return this.toFormat("hsl").withChannelValue(channel, value).toFormat("hsb");
863
+ default:
864
+ throw new Error(`Invalid channel: ${channel}`);
865
+ }
866
+ }
867
+
868
+ getChannelRange(channel: ColorChannel): ColorChannelRange {
869
+ switch (channel) {
870
+ case "hue":
871
+ return HUE_CHANNEL_RANGE;
872
+ case "saturation":
873
+ case "brightness":
874
+ case "lightness":
875
+ return PERCENT_CHANNEL_RANGE;
876
+ case "alpha":
877
+ return ALPHA_CHANNEL_RANGE;
878
+ case "red":
879
+ case "green":
880
+ case "blue":
881
+ return RGB_CHANNEL_RANGE;
882
+ default:
883
+ throw new Error(`Invalid channel: ${channel}`);
884
+ }
885
+ }
886
+
887
+ getChannelName(channel: ColorChannel, _locale: string): string {
888
+ return CHANNEL_NAMES[channel] || channel;
889
+ }
890
+
891
+ getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
892
+ if (channel === "alpha") {
893
+ return { style: "percent" };
894
+ }
895
+ if (channel === "hue") {
896
+ return { style: "unit", unit: "degree", unitDisplay: "narrow", maximumFractionDigits: 0 };
897
+ }
898
+ return { style: "percent", maximumFractionDigits: 0 };
899
+ }
900
+
901
+ formatChannelValue(channel: ColorChannel, locale: string): string {
902
+ const value = this.getChannelValue(channel);
903
+ const options = this.getChannelFormatOptions(channel);
904
+ if (channel === "saturation" || channel === "brightness") {
905
+ return new Intl.NumberFormat(locale, options).format(value / 100);
906
+ }
907
+ return new Intl.NumberFormat(locale, options).format(value);
908
+ }
909
+
910
+ getColorSpace(): ColorSpace {
911
+ return "hsb";
912
+ }
913
+
914
+ getColorSpaceAxes(xyChannels?: { xChannel?: ColorChannel; yChannel?: ColorChannel }): ColorAxes {
915
+ const xChannel = xyChannels?.xChannel ?? "saturation";
916
+ const yChannel = xyChannels?.yChannel ?? "brightness";
917
+ const channels: ColorChannel[] = ["hue", "saturation", "brightness"];
918
+ const zChannel = channels.find((c) => c !== xChannel && c !== yChannel) ?? "hue";
919
+ return { xChannel, yChannel, zChannel };
920
+ }
921
+
922
+ getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
923
+ return ["hue", "saturation", "brightness"];
924
+ }
925
+
926
+ getColorName(_locale: string): string {
927
+ return getColorNameFromColor(this);
928
+ }
929
+
930
+ getHueName(_locale: string): string {
931
+ return getHueNameFromColor(this);
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Parse a color string into a Color object.
937
+ */
938
+ export function parseColor(value: string): Color {
939
+ const trimmed = value.trim().toLowerCase();
940
+
941
+ // Hex format
942
+ if (trimmed.startsWith("#")) {
943
+ const hex = trimmed.slice(1);
944
+ if (!/^[0-9a-f]+$/i.test(hex)) {
945
+ throw new Error(`Invalid hex color: ${value}`);
946
+ }
947
+ if (hex.length === 3) {
948
+ const r = parseInt(hex[0] + hex[0], 16);
949
+ const g = parseInt(hex[1] + hex[1], 16);
950
+ const b = parseInt(hex[2] + hex[2], 16);
951
+ return new RGBColorImpl(r, g, b);
952
+ }
953
+ if (hex.length === 4) {
954
+ const r = parseInt(hex[0] + hex[0], 16);
955
+ const g = parseInt(hex[1] + hex[1], 16);
956
+ const b = parseInt(hex[2] + hex[2], 16);
957
+ const a = parseInt(hex[3] + hex[3], 16) / 255;
958
+ return new RGBColorImpl(r, g, b, a);
959
+ }
960
+ if (hex.length === 6) {
961
+ const r = parseInt(hex.slice(0, 2), 16);
962
+ const g = parseInt(hex.slice(2, 4), 16);
963
+ const b = parseInt(hex.slice(4, 6), 16);
964
+ return new RGBColorImpl(r, g, b);
965
+ }
966
+ if (hex.length === 8) {
967
+ const r = parseInt(hex.slice(0, 2), 16);
968
+ const g = parseInt(hex.slice(2, 4), 16);
969
+ const b = parseInt(hex.slice(4, 6), 16);
970
+ const a = parseInt(hex.slice(6, 8), 16) / 255;
971
+ return new RGBColorImpl(r, g, b, a);
972
+ }
973
+ throw new Error(`Invalid hex color: ${value}`);
974
+ }
975
+
976
+ // RGB/RGBA format
977
+ const rgbMatch = trimmed.match(
978
+ /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/,
979
+ );
980
+ if (rgbMatch) {
981
+ const r = parseInt(rgbMatch[1], 10);
982
+ const g = parseInt(rgbMatch[2], 10);
983
+ const b = parseInt(rgbMatch[3], 10);
984
+ const a = rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1;
985
+ return new RGBColorImpl(r, g, b, a);
986
+ }
987
+
988
+ // HSL/HSLA format
989
+ const hslMatch = trimmed.match(
990
+ /^hsla?\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*([\d.]+))?\s*\)$/,
991
+ );
992
+ if (hslMatch) {
993
+ const h = parseInt(hslMatch[1], 10);
994
+ const s = parseInt(hslMatch[2], 10);
995
+ const l = parseInt(hslMatch[3], 10);
996
+ const a = hslMatch[4] !== undefined ? parseFloat(hslMatch[4]) : 1;
997
+ return new HSLColorImpl(h, s, l, a);
998
+ }
999
+
1000
+ // HSB/HSBA format
1001
+ const hsbMatch = trimmed.match(
1002
+ /^hsba?\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*([\d.]+))?\s*\)$/,
1003
+ );
1004
+ if (hsbMatch) {
1005
+ const h = parseInt(hsbMatch[1], 10);
1006
+ const s = parseInt(hsbMatch[2], 10);
1007
+ const b = parseInt(hsbMatch[3], 10);
1008
+ const a = hsbMatch[4] !== undefined ? parseFloat(hsbMatch[4]) : 1;
1009
+ return new HSBColorImpl(h, s, b, a);
1010
+ }
1011
+
1012
+ throw new Error(`Invalid color format: ${value}`);
1013
+ }
1014
+
1015
+ /**
1016
+ * Create an RGB color.
1017
+ */
1018
+ export function createRGBColor(red: number, green: number, blue: number, alpha: number = 1): Color {
1019
+ return new RGBColorImpl(red, green, blue, alpha);
1020
+ }
1021
+
1022
+ /**
1023
+ * Create an HSL color.
1024
+ */
1025
+ export function createHSLColor(
1026
+ hue: number,
1027
+ saturation: number,
1028
+ lightness: number,
1029
+ alpha: number = 1,
1030
+ ): Color {
1031
+ return new HSLColorImpl(hue, saturation, lightness, alpha);
1032
+ }
1033
+
1034
+ /**
1035
+ * Create an HSB color.
1036
+ */
1037
+ export function createHSBColor(
1038
+ hue: number,
1039
+ saturation: number,
1040
+ brightness: number,
1041
+ alpha: number = 1,
1042
+ ): Color {
1043
+ return new HSBColorImpl(hue, saturation, brightness, alpha);
1044
+ }
1045
+
1046
+ /**
1047
+ * Normalize a color value (string or Color) to a Color object.
1048
+ */
1049
+ export function normalizeColor(value: string | Color): Color {
1050
+ if (typeof value === "string") {
1051
+ return parseColor(value);
1052
+ }
1053
+ return value;
1054
+ }