@arolariu/components 0.4.1 → 0.5.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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * @fileoverview Tests for color conversion utilities.
3
+ * @module lib/color-conversion-utilities.test
4
+ */
5
+
6
+ import {describe, expect, it} from "vitest";
7
+ import {
8
+ adjustHexColorLightness,
9
+ calculateComplementaryHexColor,
10
+ convertHexToHslString,
11
+ convertHslToHexString,
12
+ parseHslStringToComponents,
13
+ validateHexColorFormat,
14
+ } from "./color-conversion-utilities";
15
+
16
+ describe("color-conversion-utilities", () => {
17
+ describe("convertHexToHslString", () => {
18
+ it("should convert cyan-500 (#06b6d4) to HSL", () => {
19
+ const result = convertHexToHslString("#06b6d4");
20
+ // Note: Actual computed value due to floating point math
21
+ expect(result).toBe("189 94% 43%");
22
+ });
23
+
24
+ it("should convert pink-500 (#ec4899) to HSL", () => {
25
+ const result = convertHexToHslString("#ec4899");
26
+ expect(result).toBe("330 81% 60%");
27
+ });
28
+
29
+ it("should handle hex without # prefix", () => {
30
+ const result = convertHexToHslString("06b6d4");
31
+ // Note: Same color as with # prefix, same computed HSL
32
+ expect(result).toBe("189 94% 43%");
33
+ });
34
+
35
+ it("should convert black (#000000) to HSL", () => {
36
+ const result = convertHexToHslString("#000000");
37
+ expect(result).toBe("0 0% 0%");
38
+ });
39
+
40
+ it("should convert white (#ffffff) to HSL", () => {
41
+ const result = convertHexToHslString("#ffffff");
42
+ expect(result).toBe("0 0% 100%");
43
+ });
44
+
45
+ it("should convert pure red (#ff0000) to HSL", () => {
46
+ const result = convertHexToHslString("#ff0000");
47
+ expect(result).toBe("0 100% 50%");
48
+ });
49
+
50
+ it("should convert pure green (#00ff00) to HSL", () => {
51
+ const result = convertHexToHslString("#00ff00");
52
+ expect(result).toBe("120 100% 50%");
53
+ });
54
+
55
+ it("should convert pure blue (#0000ff) to HSL", () => {
56
+ const result = convertHexToHslString("#0000ff");
57
+ expect(result).toBe("240 100% 50%");
58
+ });
59
+
60
+ it("should handle high lightness colors (l > 0.5)", () => {
61
+ // Light pink has l > 0.5, which triggers the other saturation calculation branch
62
+ const result = convertHexToHslString("#ffb6c1");
63
+ expect(result).toBe("351 100% 86%");
64
+ });
65
+
66
+ it("should handle colors where max is green", () => {
67
+ // Green dominant color
68
+ const result = convertHexToHslString("#90ee90");
69
+ expect(result).toBe("120 73% 75%");
70
+ });
71
+
72
+ it("should handle colors where g < b in red-dominant colors", () => {
73
+ // Red with more blue than green (magenta-ish)
74
+ const result = convertHexToHslString("#ff00aa");
75
+ expect(result).toBe("320 100% 50%");
76
+ });
77
+ });
78
+
79
+ describe("convertHslToHexString", () => {
80
+ it("should convert HSL to cyan-500", () => {
81
+ // Note: HSL to hex conversion may not perfectly round-trip due to rounding
82
+ const result = convertHslToHexString(189, 94, 43);
83
+ expect(result.toLowerCase()).toBe("#07b6d5");
84
+ });
85
+
86
+ it("should convert black HSL to hex", () => {
87
+ const result = convertHslToHexString(0, 0, 0);
88
+ expect(result.toLowerCase()).toBe("#000000");
89
+ });
90
+
91
+ it("should convert white HSL to hex", () => {
92
+ const result = convertHslToHexString(0, 0, 100);
93
+ expect(result.toLowerCase()).toBe("#ffffff");
94
+ });
95
+
96
+ it("should convert pure red HSL to hex", () => {
97
+ const result = convertHslToHexString(0, 100, 50);
98
+ expect(result.toLowerCase()).toBe("#ff0000");
99
+ });
100
+
101
+ it("should handle all hue ranges", () => {
102
+ // Yellow range (60)
103
+ expect(convertHslToHexString(60, 100, 50).toLowerCase()).toBe("#ffff00");
104
+ // Cyan range (180)
105
+ expect(convertHslToHexString(180, 100, 50).toLowerCase()).toBe("#00ffff");
106
+ // Magenta range (300)
107
+ expect(convertHslToHexString(300, 100, 50).toLowerCase()).toBe("#ff00ff");
108
+ });
109
+
110
+ it("should handle all hue sectors correctly", () => {
111
+ // Sector 0-60: red range
112
+ expect(convertHslToHexString(30, 100, 50).toLowerCase()).toBe("#ff8000");
113
+ // Sector 60-120: yellow-green range
114
+ expect(convertHslToHexString(90, 100, 50).toLowerCase()).toBe("#80ff00");
115
+ // Sector 120-180: green-cyan range
116
+ expect(convertHslToHexString(150, 100, 50).toLowerCase()).toBe("#00ff80");
117
+ // Sector 180-240: cyan-blue range
118
+ expect(convertHslToHexString(210, 100, 50).toLowerCase()).toBe("#0080ff");
119
+ // Sector 240-300: blue-magenta range
120
+ expect(convertHslToHexString(270, 100, 50).toLowerCase()).toBe("#8000ff");
121
+ // Sector 300-360: magenta-red range
122
+ expect(convertHslToHexString(330, 100, 50).toLowerCase()).toBe("#ff0080");
123
+ });
124
+ });
125
+
126
+ describe("validateHexColorFormat", () => {
127
+ it("should return true for valid hex with #", () => {
128
+ expect(validateHexColorFormat("#06b6d4")).toBe(true);
129
+ });
130
+
131
+ it("should return true for valid hex without #", () => {
132
+ expect(validateHexColorFormat("06b6d4")).toBe(true);
133
+ });
134
+
135
+ it("should return true for uppercase hex", () => {
136
+ expect(validateHexColorFormat("#FFFFFF")).toBe(true);
137
+ });
138
+
139
+ it("should return false for 3-digit hex", () => {
140
+ expect(validateHexColorFormat("#FFF")).toBe(false);
141
+ });
142
+
143
+ it("should return false for invalid characters", () => {
144
+ expect(validateHexColorFormat("#GGGGGG")).toBe(false);
145
+ });
146
+
147
+ it("should return false for empty string", () => {
148
+ expect(validateHexColorFormat("")).toBe(false);
149
+ });
150
+
151
+ it("should return false for random string", () => {
152
+ expect(validateHexColorFormat("invalid")).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe("calculateComplementaryHexColor", () => {
157
+ it("should return inverse of black as white", () => {
158
+ const result = calculateComplementaryHexColor("#000000");
159
+ expect(result.toLowerCase()).toBe("#ffffff");
160
+ });
161
+
162
+ it("should return inverse of white as black", () => {
163
+ const result = calculateComplementaryHexColor("#ffffff");
164
+ expect(result.toLowerCase()).toBe("#000000");
165
+ });
166
+
167
+ it("should return cyan for red", () => {
168
+ const result = calculateComplementaryHexColor("#ff0000");
169
+ expect(result.toLowerCase()).toBe("#00ffff");
170
+ });
171
+
172
+ it("should handle hex without #", () => {
173
+ const result = calculateComplementaryHexColor("ff0000");
174
+ expect(result.toLowerCase()).toBe("#00ffff");
175
+ });
176
+ });
177
+
178
+ describe("adjustHexColorLightness", () => {
179
+ it("should lighten a color", () => {
180
+ const result = adjustHexColorLightness("#000000", 50);
181
+ expect(result).not.toBe("#000000");
182
+ });
183
+
184
+ it("should darken a color", () => {
185
+ const result = adjustHexColorLightness("#ffffff", -50);
186
+ expect(result).not.toBe("#ffffff");
187
+ });
188
+
189
+ it("should not exceed 100% lightness", () => {
190
+ const result = adjustHexColorLightness("#ffffff", 100);
191
+ // Should cap at white
192
+ expect(result.toLowerCase()).toBe("#ffffff");
193
+ });
194
+
195
+ it("should not go below 0% lightness", () => {
196
+ const result = adjustHexColorLightness("#000000", -100);
197
+ // Should cap at black
198
+ expect(result.toLowerCase()).toBe("#000000");
199
+ });
200
+ });
201
+
202
+ describe("parseHslStringToComponents", () => {
203
+ it("should parse valid HSL string", () => {
204
+ const result = parseHslStringToComponents("187 94% 43%");
205
+ expect(result).toEqual({hue: 187, saturation: 94, lightness: 43});
206
+ });
207
+
208
+ it("should parse HSL with zero values", () => {
209
+ const result = parseHslStringToComponents("0 0% 0%");
210
+ expect(result).toEqual({hue: 0, saturation: 0, lightness: 0});
211
+ });
212
+
213
+ it("should return null for invalid format", () => {
214
+ expect(parseHslStringToComponents("invalid")).toBeNull();
215
+ });
216
+
217
+ it("should return null for empty string", () => {
218
+ expect(parseHslStringToComponents("")).toBeNull();
219
+ });
220
+
221
+ it("should return null for partial HSL", () => {
222
+ expect(parseHslStringToComponents("187 94%")).toBeNull();
223
+ });
224
+ });
225
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @fileoverview Utility functions for color conversion and manipulation.
3
+ * Provides hex-to-HSL conversion and color validation for CSS custom properties.
4
+ * @module lib/color-conversion-utilities
5
+ */
6
+
7
+ /**
8
+ * Converts a hexadecimal color code to an HSL string for CSS variables.
9
+ * The output format matches Tailwind CSS HSL variable format: "h s% l%"
10
+ *
11
+ * @param hexColor - Hex color code (e.g., "#06b6d4" or "06b6d4")
12
+ * @returns HSL values as "h s% l%" string suitable for CSS variables
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * convertHexToHslString("#06b6d4"); // "187 94% 43%"
17
+ * convertHexToHslString("#ec4899"); // "330 81% 60%"
18
+ * ```
19
+ */
20
+ export function convertHexToHslString(hexColor: string): string {
21
+ // Remove # if present
22
+ const cleanHex = hexColor.replace("#", "");
23
+
24
+ // Parse RGB values
25
+ const r = Number.parseInt(cleanHex.slice(0, 2), 16) / 255;
26
+ const g = Number.parseInt(cleanHex.slice(2, 4), 16) / 255;
27
+ const b = Number.parseInt(cleanHex.slice(4, 6), 16) / 255;
28
+
29
+ const max = Math.max(r, g, b);
30
+ const min = Math.min(r, g, b);
31
+ let h = 0;
32
+ let s = 0;
33
+ const l = (max + min) / 2;
34
+
35
+ if (max !== min) {
36
+ const d = max - min;
37
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
38
+
39
+ // max is always r, g, or b - no default case needed
40
+ if (max === r) {
41
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
42
+ } else if (max === g) {
43
+ h = ((b - r) / d + 2) / 6;
44
+ } else {
45
+ h = ((r - g) / d + 4) / 6;
46
+ }
47
+ }
48
+
49
+ return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
50
+ }
51
+
52
+ /**
53
+ * Converts HSL color values to a hexadecimal color string.
54
+ *
55
+ * @param hue - Hue value (0-360)
56
+ * @param saturation - Saturation percentage (0-100)
57
+ * @param lightness - Lightness percentage (0-100)
58
+ * @returns Hex color code (e.g., "#06b6d4")
59
+ */
60
+ export function convertHslToHexString(hue: number, saturation: number, lightness: number): string {
61
+ const sNorm = saturation / 100;
62
+ const lNorm = lightness / 100;
63
+
64
+ const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
65
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
66
+ const m = lNorm - c / 2;
67
+
68
+ const getRgb = (): [number, number, number] => {
69
+ if (hue >= 0 && hue < 60) return [c, x, 0];
70
+ if (hue >= 60 && hue < 120) return [x, c, 0];
71
+ if (hue >= 120 && hue < 180) return [0, c, x];
72
+ if (hue >= 180 && hue < 240) return [0, x, c];
73
+ if (hue >= 240 && hue < 300) return [x, 0, c];
74
+ return [c, 0, x];
75
+ };
76
+
77
+ const rgb = getRgb();
78
+
79
+ const toHex = (n: number) =>
80
+ Math.round((n + m) * 255)
81
+ .toString(16)
82
+ .padStart(2, "0");
83
+
84
+ return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`;
85
+ }
86
+
87
+ /**
88
+ * Validates whether a string is a valid 6-digit hexadecimal color code.
89
+ *
90
+ * @param hexColor - String to validate
91
+ * @returns True if valid 6-digit hex color (with or without #)
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * validateHexColorFormat("#06b6d4"); // true
96
+ * validateHexColorFormat("06b6d4"); // true
97
+ * validateHexColorFormat("#FFF"); // false (3-digit not supported)
98
+ * validateHexColorFormat("invalid"); // false
99
+ * ```
100
+ */
101
+ export function validateHexColorFormat(hexColor: string): boolean {
102
+ return /^#?[\dA-Fa-f]{6}$/u.test(hexColor);
103
+ }
104
+
105
+ /**
106
+ * Generates the complementary (inverse) color for a given hex color.
107
+ *
108
+ * @param hexColor - Hex color code
109
+ * @returns Complementary hex color code
110
+ */
111
+ export function calculateComplementaryHexColor(hexColor: string): string {
112
+ const cleanHex = hexColor.replace("#", "");
113
+ const r = 255 - Number.parseInt(cleanHex.slice(0, 2), 16);
114
+ const g = 255 - Number.parseInt(cleanHex.slice(2, 4), 16);
115
+ const b = 255 - Number.parseInt(cleanHex.slice(4, 6), 16);
116
+
117
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
118
+ }
119
+
120
+ /**
121
+ * Adjusts the lightness of a hexadecimal color by a specified amount.
122
+ *
123
+ * @param hexColor - Hex color code
124
+ * @param lightnessAdjustment - Amount to adjust lightness (-100 to 100)
125
+ * @returns Adjusted hex color code
126
+ */
127
+ export function adjustHexColorLightness(hexColor: string, lightnessAdjustment: number): string {
128
+ const hsl = convertHexToHslString(hexColor);
129
+ const [h, s, l] = hsl.split(" ").map((v, i) => (i === 0 ? Number.parseInt(v, 10) : Number.parseInt(v.replace("%", ""), 10)));
130
+
131
+ const newL = Math.max(0, Math.min(100, (l ?? 50) + lightnessAdjustment));
132
+ return convertHslToHexString(h ?? 0, s ?? 50, newL);
133
+ }
134
+
135
+ /**
136
+ * Parses an HSL CSS variable string into its numeric components.
137
+ *
138
+ * @param hslString - HSL string in format "h s% l%"
139
+ * @returns Object with hue, saturation, lightness values or null if invalid
140
+ */
141
+ export function parseHslStringToComponents(hslString: string): {hue: number; saturation: number; lightness: number} | null {
142
+ const pattern = /^(?<hue>\d+)\s+(?<sat>\d+)%\s+(?<light>\d+)%$/u;
143
+ const match = pattern.exec(hslString);
144
+ if (!match?.groups) return null;
145
+
146
+ return {
147
+ hue: Number.parseInt(match.groups["hue"] ?? "0", 10),
148
+ saturation: Number.parseInt(match.groups["sat"] ?? "0", 10),
149
+ lightness: Number.parseInt(match.groups["light"] ?? "0", 10),
150
+ };
151
+ }
152
+
153
+ // Legacy aliases for backwards compatibility (deprecated)
154
+ /** @deprecated Use convertHexToHslString instead */
155
+ export const hexToHsl = convertHexToHslString;
156
+ /** @deprecated Use convertHslToHexString instead */
157
+ export const hslToHex = convertHslToHexString;
158
+ /** @deprecated Use validateHexColorFormat instead */
159
+ export const isValidHexColor = validateHexColorFormat;
160
+ /** @deprecated Use calculateComplementaryHexColor instead */
161
+ export const getComplementaryColor = calculateComplementaryHexColor;
162
+ /** @deprecated Use adjustHexColorLightness instead */
163
+ export const adjustLightness = adjustHexColorLightness;
164
+ /** @deprecated Use parseHslStringToComponents instead */
165
+ export const parseHslString = parseHslStringToComponents;
@@ -0,0 +1,37 @@
1
+ import {describe, expect, it} from "vitest";
2
+ import {cn} from "./utilities";
3
+
4
+ describe("cn utility", () => {
5
+ it("should merge class names", () => {
6
+ expect(cn("class1", "class2")).toBe("class1 class2");
7
+ });
8
+
9
+ it("should handle conditional classes", () => {
10
+ expect(cn("class1", true && "class2", false && "class3")).toBe("class1 class2");
11
+ });
12
+
13
+ it("should handle null and undefined", () => {
14
+ expect(cn("class1", null, undefined)).toBe("class1");
15
+ });
16
+
17
+ it("should merge tailwind classes correctly", () => {
18
+ expect(cn("px-2", "px-4")).toBe("px-4");
19
+ expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500");
20
+ });
21
+
22
+ it("should handle arrays of classes", () => {
23
+ expect(cn(["class1", "class2"])).toBe("class1 class2");
24
+ });
25
+
26
+ it("should handle objects of classes", () => {
27
+ expect(cn({class1: true, class2: false, class3: true})).toBe("class1 class3");
28
+ });
29
+
30
+ it("should handle complex nested structures", () => {
31
+ expect(cn("base", ["nested1", {nested2: true}], {obj1: false, obj2: true})).toBe("base nested1 nested2 obj2");
32
+ });
33
+
34
+ it("should handle empty input", () => {
35
+ expect(cn()).toBe("");
36
+ });
37
+ });