@sonny-ui/core 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (242) hide show
  1. package/README.md +187 -40
  2. package/fesm2022/sonny-ui-core.mjs +6646 -272
  3. package/fesm2022/sonny-ui-core.mjs.map +1 -1
  4. package/package.json +8 -5
  5. package/schematics/ng-add/index.js +27 -0
  6. package/schematics/ng-add/schema.json +1 -1
  7. package/schematics/ng-generate/component/index.js +182 -1
  8. package/schematics/ng-generate/component/schema.json +2 -2
  9. package/src/lib/accordion/accordion.directives.spec.ts +173 -0
  10. package/src/lib/accordion/accordion.directives.ts +143 -0
  11. package/src/lib/accordion/index.ts +8 -0
  12. package/src/lib/alert/alert.directives.spec.ts +154 -0
  13. package/src/lib/alert/alert.directives.ts +67 -0
  14. package/src/lib/alert/alert.variants.ts +25 -0
  15. package/src/lib/alert/index.ts +6 -0
  16. package/src/lib/avatar/avatar.component.spec.ts +75 -0
  17. package/src/lib/avatar/avatar.component.ts +43 -0
  18. package/src/lib/avatar/avatar.variants.ts +26 -0
  19. package/src/lib/avatar/index.ts +2 -0
  20. package/src/lib/avatar-group/avatar-group.component.spec.ts +74 -0
  21. package/src/lib/avatar-group/avatar-group.component.ts +88 -0
  22. package/src/lib/avatar-group/index.ts +1 -0
  23. package/src/lib/badge/badge.directive.spec.ts +74 -0
  24. package/src/lib/badge/badge.directive.ts +17 -0
  25. package/src/lib/badge/badge.variants.ts +29 -0
  26. package/src/lib/badge/index.ts +2 -0
  27. package/src/lib/breadcrumb/breadcrumb.directives.spec.ts +80 -0
  28. package/src/lib/breadcrumb/breadcrumb.directives.ts +78 -0
  29. package/src/lib/breadcrumb/index.ts +8 -0
  30. package/src/lib/button/button.directive.spec.ts +92 -0
  31. package/src/lib/button/button.directive.ts +28 -0
  32. package/src/lib/button/button.variants.ts +30 -0
  33. package/src/lib/button/index.ts +2 -0
  34. package/src/lib/button-group/button-group.directive.spec.ts +46 -0
  35. package/src/lib/button-group/button-group.directive.ts +19 -0
  36. package/src/lib/button-group/button-group.variants.ts +18 -0
  37. package/src/lib/button-group/index.ts +2 -0
  38. package/src/lib/calendar/calendar.component.spec.ts +192 -0
  39. package/src/lib/calendar/calendar.component.ts +342 -0
  40. package/src/lib/calendar/calendar.types.ts +24 -0
  41. package/src/lib/calendar/index.ts +7 -0
  42. package/src/lib/card/card.directives.spec.ts +104 -0
  43. package/src/lib/card/card.directives.ts +72 -0
  44. package/src/lib/card/card.variants.ts +28 -0
  45. package/src/lib/card/index.ts +9 -0
  46. package/src/lib/carousel/carousel.directives.spec.ts +85 -0
  47. package/src/lib/carousel/carousel.directives.ts +159 -0
  48. package/src/lib/carousel/index.ts +8 -0
  49. package/src/lib/chat-bubble/chat-bubble.directives.spec.ts +52 -0
  50. package/src/lib/chat-bubble/chat-bubble.directives.ts +96 -0
  51. package/src/lib/chat-bubble/index.ts +11 -0
  52. package/src/lib/checkbox/checkbox.directive.spec.ts +57 -0
  53. package/src/lib/checkbox/checkbox.directive.ts +16 -0
  54. package/src/lib/checkbox/checkbox.variants.ts +19 -0
  55. package/src/lib/checkbox/index.ts +2 -0
  56. package/src/lib/color-picker/color-picker.component.spec.ts +328 -0
  57. package/src/lib/color-picker/color-picker.component.ts +537 -0
  58. package/src/lib/color-picker/color-picker.types.ts +24 -0
  59. package/src/lib/color-picker/color-picker.utils.ts +183 -0
  60. package/src/lib/color-picker/color-picker.variants.ts +17 -0
  61. package/src/lib/color-picker/index.ts +20 -0
  62. package/src/lib/combobox/combobox.component.spec.ts +151 -0
  63. package/src/lib/combobox/combobox.component.ts +264 -0
  64. package/src/lib/combobox/combobox.variants.ts +19 -0
  65. package/src/lib/combobox/index.ts +2 -0
  66. package/src/lib/command-palette/command-palette.component.spec.ts +178 -0
  67. package/src/lib/command-palette/command-palette.component.ts +194 -0
  68. package/src/lib/command-palette/command-palette.service.ts +36 -0
  69. package/src/lib/command-palette/command-palette.types.ts +23 -0
  70. package/src/lib/command-palette/index.ts +7 -0
  71. package/src/lib/data-table/data-table.component.spec.ts +443 -0
  72. package/src/lib/data-table/data-table.component.ts +602 -0
  73. package/src/lib/data-table/data-table.directives.ts +31 -0
  74. package/src/lib/data-table/data-table.types.ts +20 -0
  75. package/src/lib/data-table/index.ts +13 -0
  76. package/src/lib/date-picker/date-picker.component.spec.ts +131 -0
  77. package/src/lib/date-picker/date-picker.component.ts +220 -0
  78. package/src/lib/date-picker/date-picker.variants.ts +17 -0
  79. package/src/lib/date-picker/index.ts +2 -0
  80. package/src/lib/date-range-picker/date-range-picker.component.spec.ts +151 -0
  81. package/src/lib/date-range-picker/date-range-picker.component.ts +340 -0
  82. package/src/lib/date-range-picker/index.ts +1 -0
  83. package/src/lib/diff/diff.component.spec.ts +47 -0
  84. package/src/lib/diff/diff.component.ts +82 -0
  85. package/src/lib/diff/index.ts +1 -0
  86. package/src/lib/divider/divider.component.spec.ts +48 -0
  87. package/src/lib/divider/divider.component.ts +51 -0
  88. package/src/lib/divider/divider.variants.ts +22 -0
  89. package/src/lib/divider/index.ts +2 -0
  90. package/src/lib/dock/dock.directives.spec.ts +85 -0
  91. package/src/lib/dock/dock.directives.ts +81 -0
  92. package/src/lib/dock/index.ts +1 -0
  93. package/src/lib/drawer/drawer.directives.spec.ts +62 -0
  94. package/src/lib/drawer/drawer.directives.ts +80 -0
  95. package/src/lib/drawer/index.ts +8 -0
  96. package/src/lib/dropdown/dropdown.directives.spec.ts +106 -0
  97. package/src/lib/dropdown/dropdown.directives.ts +136 -0
  98. package/src/lib/dropdown/dropdown.variants.ts +27 -0
  99. package/src/lib/dropdown/index.ts +15 -0
  100. package/src/lib/fab/fab.directives.spec.ts +60 -0
  101. package/src/lib/fab/fab.directives.ts +77 -0
  102. package/src/lib/fab/index.ts +8 -0
  103. package/src/lib/fieldset/fieldset.directives.spec.ts +74 -0
  104. package/src/lib/fieldset/fieldset.directives.ts +49 -0
  105. package/src/lib/fieldset/fieldset.variants.ts +15 -0
  106. package/src/lib/fieldset/index.ts +6 -0
  107. package/src/lib/file-input/file-input.component.spec.ts +114 -0
  108. package/src/lib/file-input/file-input.component.ts +155 -0
  109. package/src/lib/file-input/file-input.variants.ts +25 -0
  110. package/src/lib/file-input/index.ts +6 -0
  111. package/src/lib/indicator/index.ts +6 -0
  112. package/src/lib/indicator/indicator.directives.spec.ts +64 -0
  113. package/src/lib/indicator/indicator.directives.ts +59 -0
  114. package/src/lib/input/index.ts +3 -0
  115. package/src/lib/input/input.directive.spec.ts +103 -0
  116. package/src/lib/input/input.directive.ts +25 -0
  117. package/src/lib/input/input.variants.ts +42 -0
  118. package/src/lib/input/label.directive.ts +16 -0
  119. package/src/lib/kbd/index.ts +2 -0
  120. package/src/lib/kbd/kbd.directive.spec.ts +42 -0
  121. package/src/lib/kbd/kbd.directive.ts +18 -0
  122. package/src/lib/kbd/kbd.variants.ts +19 -0
  123. package/src/lib/link/index.ts +2 -0
  124. package/src/lib/link/link.directive.spec.ts +41 -0
  125. package/src/lib/link/link.directive.ts +18 -0
  126. package/src/lib/link/link.variants.ts +20 -0
  127. package/src/lib/list/index.ts +8 -0
  128. package/src/lib/list/list.directives.spec.ts +65 -0
  129. package/src/lib/list/list.directives.ts +81 -0
  130. package/src/lib/loader/index.ts +2 -0
  131. package/src/lib/loader/loader.component.spec.ts +58 -0
  132. package/src/lib/loader/loader.component.ts +47 -0
  133. package/src/lib/loader/loader.variants.ts +21 -0
  134. package/src/lib/modal/dialog-ref.ts +19 -0
  135. package/src/lib/modal/dialog.directives.ts +84 -0
  136. package/src/lib/modal/dialog.service.spec.ts +52 -0
  137. package/src/lib/modal/dialog.service.ts +61 -0
  138. package/src/lib/modal/dialog.types.ts +16 -0
  139. package/src/lib/modal/index.ts +11 -0
  140. package/src/lib/navbar/index.ts +7 -0
  141. package/src/lib/navbar/navbar.directives.spec.ts +59 -0
  142. package/src/lib/navbar/navbar.directives.ts +57 -0
  143. package/src/lib/number-input/index.ts +2 -0
  144. package/src/lib/number-input/number-input.component.spec.ts +151 -0
  145. package/src/lib/number-input/number-input.component.ts +152 -0
  146. package/src/lib/number-input/number-input.variants.ts +17 -0
  147. package/src/lib/otp-input/index.ts +2 -0
  148. package/src/lib/otp-input/otp-input.component.spec.ts +252 -0
  149. package/src/lib/otp-input/otp-input.component.ts +274 -0
  150. package/src/lib/otp-input/otp-input.variants.ts +18 -0
  151. package/src/lib/pagination/index.ts +6 -0
  152. package/src/lib/pagination/pagination.component.spec.ts +59 -0
  153. package/src/lib/pagination/pagination.component.ts +143 -0
  154. package/src/lib/pagination/pagination.variants.ts +31 -0
  155. package/src/lib/popover/index.ts +6 -0
  156. package/src/lib/popover/popover.directives.spec.ts +147 -0
  157. package/src/lib/popover/popover.directives.ts +151 -0
  158. package/src/lib/progress/index.ts +7 -0
  159. package/src/lib/progress/progress.component.spec.ts +117 -0
  160. package/src/lib/progress/progress.component.ts +64 -0
  161. package/src/lib/progress/progress.variants.ts +43 -0
  162. package/src/lib/radial-progress/index.ts +5 -0
  163. package/src/lib/radial-progress/radial-progress.component.spec.ts +41 -0
  164. package/src/lib/radial-progress/radial-progress.component.ts +70 -0
  165. package/src/lib/radio/index.ts +2 -0
  166. package/src/lib/radio/radio.directive.spec.ts +46 -0
  167. package/src/lib/radio/radio.directive.ts +16 -0
  168. package/src/lib/radio/radio.variants.ts +19 -0
  169. package/src/lib/rating/index.ts +2 -0
  170. package/src/lib/rating/rating.component.spec.ts +157 -0
  171. package/src/lib/rating/rating.component.ts +163 -0
  172. package/src/lib/rating/rating.variants.ts +20 -0
  173. package/src/lib/select/index.ts +2 -0
  174. package/src/lib/select/select.component.spec.ts +112 -0
  175. package/src/lib/select/select.component.ts +235 -0
  176. package/src/lib/select/select.variants.ts +19 -0
  177. package/src/lib/sheet/index.ts +10 -0
  178. package/src/lib/sheet/sheet-ref.ts +18 -0
  179. package/src/lib/sheet/sheet.component.spec.ts +67 -0
  180. package/src/lib/sheet/sheet.directives.ts +70 -0
  181. package/src/lib/sheet/sheet.service.ts +100 -0
  182. package/src/lib/sheet/sheet.types.ts +23 -0
  183. package/src/lib/skeleton/index.ts +2 -0
  184. package/src/lib/skeleton/skeleton.directive.spec.ts +63 -0
  185. package/src/lib/skeleton/skeleton.directive.ts +21 -0
  186. package/src/lib/skeleton/skeleton.variants.ts +27 -0
  187. package/src/lib/slider/index.ts +2 -0
  188. package/src/lib/slider/slider.component.spec.ts +104 -0
  189. package/src/lib/slider/slider.component.ts +181 -0
  190. package/src/lib/slider/slider.variants.ts +25 -0
  191. package/src/lib/stat/index.ts +8 -0
  192. package/src/lib/stat/stat.directives.spec.ts +60 -0
  193. package/src/lib/stat/stat.directives.ts +79 -0
  194. package/src/lib/status/index.ts +2 -0
  195. package/src/lib/status/status.directive.spec.ts +43 -0
  196. package/src/lib/status/status.directive.ts +37 -0
  197. package/src/lib/status/status.variants.ts +26 -0
  198. package/src/lib/steps/index.ts +8 -0
  199. package/src/lib/steps/steps.directives.spec.ts +52 -0
  200. package/src/lib/steps/steps.directives.ts +78 -0
  201. package/src/lib/switch/index.ts +2 -0
  202. package/src/lib/switch/switch.component.spec.ts +98 -0
  203. package/src/lib/switch/switch.component.ts +76 -0
  204. package/src/lib/switch/switch.variants.ts +31 -0
  205. package/src/lib/table/index.ts +12 -0
  206. package/src/lib/table/table.directives.spec.ts +111 -0
  207. package/src/lib/table/table.directives.ts +126 -0
  208. package/src/lib/table/table.variants.ts +36 -0
  209. package/src/lib/tabs/index.ts +8 -0
  210. package/src/lib/tabs/tabs.directives.spec.ts +136 -0
  211. package/src/lib/tabs/tabs.directives.ts +126 -0
  212. package/src/lib/tabs/tabs.variants.ts +17 -0
  213. package/src/lib/tag-input/index.ts +2 -0
  214. package/src/lib/tag-input/tag-input.component.spec.ts +190 -0
  215. package/src/lib/tag-input/tag-input.component.ts +172 -0
  216. package/src/lib/tag-input/tag-input.variants.ts +31 -0
  217. package/src/lib/textarea/index.ts +7 -0
  218. package/src/lib/textarea/textarea.directive.spec.ts +84 -0
  219. package/src/lib/textarea/textarea.directive.ts +71 -0
  220. package/src/lib/textarea/textarea.variants.ts +34 -0
  221. package/src/lib/timeline/index.ts +11 -0
  222. package/src/lib/timeline/timeline.directives.spec.ts +55 -0
  223. package/src/lib/timeline/timeline.directives.ts +85 -0
  224. package/src/lib/toast/index.ts +3 -0
  225. package/src/lib/toast/toast.service.spec.ts +71 -0
  226. package/src/lib/toast/toast.service.ts +60 -0
  227. package/src/lib/toast/toast.variants.ts +38 -0
  228. package/src/lib/toast/toaster.component.spec.ts +38 -0
  229. package/src/lib/toast/toaster.component.ts +81 -0
  230. package/src/lib/toggle/index.ts +2 -0
  231. package/src/lib/toggle/toggle.directive.spec.ts +100 -0
  232. package/src/lib/toggle/toggle.directive.ts +61 -0
  233. package/src/lib/toggle/toggle.variants.ts +25 -0
  234. package/src/lib/tooltip/index.ts +2 -0
  235. package/src/lib/tooltip/tooltip.directive.spec.ts +113 -0
  236. package/src/lib/tooltip/tooltip.directive.ts +130 -0
  237. package/src/lib/tooltip/tooltip.variants.ts +20 -0
  238. package/src/lib/validator/index.ts +5 -0
  239. package/src/lib/validator/validator.directives.spec.ts +47 -0
  240. package/src/lib/validator/validator.directives.ts +50 -0
  241. package/src/styles/sonny-theme.css +45 -0
  242. package/types/sonny-ui-core.d.ts +1443 -13
@@ -0,0 +1,183 @@
1
+ import type { RGB, HSL, HSV, ColorFormat } from './color-picker.types';
2
+
3
+ export function hexToRgb(hex: string): RGB | null {
4
+ const clean = hex.replace(/^#/, '');
5
+ if (!/^[0-9a-fA-F]+$/.test(clean)) return null;
6
+ if (clean.length === 3) {
7
+ const r = parseInt(clean[0] + clean[0], 16);
8
+ const g = parseInt(clean[1] + clean[1], 16);
9
+ const b = parseInt(clean[2] + clean[2], 16);
10
+ return { r, g, b };
11
+ }
12
+ if (clean.length === 6) {
13
+ const r = parseInt(clean.slice(0, 2), 16);
14
+ const g = parseInt(clean.slice(2, 4), 16);
15
+ const b = parseInt(clean.slice(4, 6), 16);
16
+ return { r, g, b };
17
+ }
18
+ return null;
19
+ }
20
+
21
+ export function rgbToHex(rgb: RGB): string {
22
+ const toHex = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0');
23
+ return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
24
+ }
25
+
26
+ export function rgbToHsl(rgb: RGB): HSL {
27
+ const r = rgb.r / 255;
28
+ const g = rgb.g / 255;
29
+ const b = rgb.b / 255;
30
+ const max = Math.max(r, g, b);
31
+ const min = Math.min(r, g, b);
32
+ const l = (max + min) / 2;
33
+ let h = 0;
34
+ let s = 0;
35
+
36
+ if (max !== min) {
37
+ const d = max - min;
38
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
39
+ switch (max) {
40
+ case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
41
+ case g: h = ((b - r) / d + 2) / 6; break;
42
+ case b: h = ((r - g) / d + 4) / 6; break;
43
+ }
44
+ }
45
+
46
+ return {
47
+ h: Math.round(h * 360),
48
+ s: Math.round(s * 100),
49
+ l: Math.round(l * 100),
50
+ };
51
+ }
52
+
53
+ export function hslToRgb(hsl: HSL): RGB {
54
+ const h = hsl.h / 360;
55
+ const s = hsl.s / 100;
56
+ const l = hsl.l / 100;
57
+
58
+ if (s === 0) {
59
+ const v = Math.round(l * 255);
60
+ return { r: v, g: v, b: v };
61
+ }
62
+
63
+ const hue2rgb = (p: number, q: number, t: number): number => {
64
+ if (t < 0) t += 1;
65
+ if (t > 1) t -= 1;
66
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
67
+ if (t < 1 / 2) return q;
68
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
69
+ return p;
70
+ };
71
+
72
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
73
+ const p = 2 * l - q;
74
+
75
+ return {
76
+ r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
77
+ g: Math.round(hue2rgb(p, q, h) * 255),
78
+ b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
79
+ };
80
+ }
81
+
82
+ export function rgbToHsv(rgb: RGB): HSV {
83
+ const r = rgb.r / 255;
84
+ const g = rgb.g / 255;
85
+ const b = rgb.b / 255;
86
+ const max = Math.max(r, g, b);
87
+ const min = Math.min(r, g, b);
88
+ const d = max - min;
89
+ let h = 0;
90
+ const s = max === 0 ? 0 : d / max;
91
+ const v = max;
92
+
93
+ if (max !== min) {
94
+ switch (max) {
95
+ case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
96
+ case g: h = ((b - r) / d + 2) / 6; break;
97
+ case b: h = ((r - g) / d + 4) / 6; break;
98
+ }
99
+ }
100
+
101
+ return { h: Math.round(h * 360), s, v };
102
+ }
103
+
104
+ export function hsvToRgb(hsv: HSV): RGB {
105
+ const h = hsv.h / 360;
106
+ const s = hsv.s;
107
+ const v = hsv.v;
108
+ const i = Math.floor(h * 6);
109
+ const f = h * 6 - i;
110
+ const p = v * (1 - s);
111
+ const q = v * (1 - f * s);
112
+ const t = v * (1 - (1 - f) * s);
113
+
114
+ let r: number, g: number, b: number;
115
+ switch (i % 6) {
116
+ case 0: r = v; g = t; b = p; break;
117
+ case 1: r = q; g = v; b = p; break;
118
+ case 2: r = p; g = v; b = t; break;
119
+ case 3: r = p; g = q; b = v; break;
120
+ case 4: r = t; g = p; b = v; break;
121
+ default: r = v; g = p; b = q; break;
122
+ }
123
+
124
+ return {
125
+ r: Math.round(r * 255),
126
+ g: Math.round(g * 255),
127
+ b: Math.round(b * 255),
128
+ };
129
+ }
130
+
131
+ export function parseColor(input: string): RGB | null {
132
+ const trimmed = input.trim().toLowerCase();
133
+
134
+ // HEX
135
+ if (trimmed.startsWith('#')) {
136
+ return hexToRgb(trimmed);
137
+ }
138
+
139
+ // rgb(r, g, b)
140
+ const rgbMatch = trimmed.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
141
+ if (rgbMatch) {
142
+ return {
143
+ r: Math.min(255, parseInt(rgbMatch[1])),
144
+ g: Math.min(255, parseInt(rgbMatch[2])),
145
+ b: Math.min(255, parseInt(rgbMatch[3])),
146
+ };
147
+ }
148
+
149
+ // hsl(h, s%, l%)
150
+ const hslMatch = trimmed.match(/^hsl\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%\s*\)$/);
151
+ if (hslMatch) {
152
+ return hslToRgb({
153
+ h: Math.min(360, parseInt(hslMatch[1])),
154
+ s: Math.min(100, parseInt(hslMatch[2])),
155
+ l: Math.min(100, parseInt(hslMatch[3])),
156
+ });
157
+ }
158
+
159
+ return null;
160
+ }
161
+
162
+ export function formatColor(rgb: RGB, format: ColorFormat, alpha?: number): string {
163
+ switch (format) {
164
+ case 'hex':
165
+ return rgbToHex(rgb);
166
+ case 'rgb':
167
+ if (alpha !== undefined && alpha < 1) {
168
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha.toFixed(2)})`;
169
+ }
170
+ return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
171
+ case 'hsl': {
172
+ const hsl = rgbToHsl(rgb);
173
+ if (alpha !== undefined && alpha < 1) {
174
+ return `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${alpha.toFixed(2)})`;
175
+ }
176
+ return `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`;
177
+ }
178
+ }
179
+ }
180
+
181
+ export function isValidColor(input: string): boolean {
182
+ return parseColor(input) !== null;
183
+ }
@@ -0,0 +1,17 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const colorPickerTriggerVariants = cva(
4
+ 'inline-flex w-full items-center gap-2 whitespace-nowrap rounded-sm border border-border bg-background px-3 py-2 text-sm ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: 'h-9 text-xs',
9
+ md: 'h-10 text-sm',
10
+ lg: 'h-11 text-base',
11
+ },
12
+ },
13
+ defaultVariants: { size: 'md' },
14
+ }
15
+ );
16
+
17
+ export type ColorPickerSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,20 @@
1
+ export { SnyColorPickerComponent } from './color-picker.component';
2
+ export { colorPickerTriggerVariants, type ColorPickerSize } from './color-picker.variants';
3
+ export type {
4
+ ColorFormat,
5
+ RGB,
6
+ HSL,
7
+ HSV,
8
+ ColorPickerPreset,
9
+ } from './color-picker.types';
10
+ export {
11
+ hexToRgb,
12
+ rgbToHex,
13
+ rgbToHsl,
14
+ hslToRgb,
15
+ rgbToHsv,
16
+ hsvToRgb,
17
+ parseColor,
18
+ formatColor,
19
+ isValidColor,
20
+ } from './color-picker.utils';
@@ -0,0 +1,151 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
4
+ import { SnyComboboxComponent, type ComboboxOption } from './combobox.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyComboboxComponent],
9
+ template: `<sny-combobox [options]="options" [(value)]="value" placeholder="Select..." searchPlaceholder="Search..." />`,
10
+ })
11
+ class TestHostComponent {
12
+ value = signal('');
13
+ options: ComboboxOption[] = [
14
+ { value: 'us', label: 'United States' },
15
+ { value: 'uk', label: 'United Kingdom' },
16
+ { value: 'ca', label: 'Canada' },
17
+ ];
18
+ }
19
+
20
+ describe('SnyComboboxComponent', () => {
21
+ let fixture: ComponentFixture<TestHostComponent>;
22
+ let el: HTMLElement;
23
+ let trigger: HTMLButtonElement;
24
+
25
+ beforeEach(async () => {
26
+ await TestBed.configureTestingModule({
27
+ imports: [TestHostComponent],
28
+ }).compileComponents();
29
+
30
+ fixture = TestBed.createComponent(TestHostComponent);
31
+ fixture.detectChanges();
32
+ el = fixture.nativeElement.querySelector('sny-combobox');
33
+ trigger = el.querySelector('button')!;
34
+ });
35
+
36
+ it('should render the trigger button', () => {
37
+ expect(trigger).toBeTruthy();
38
+ expect(trigger.getAttribute('role')).toBe('combobox');
39
+ });
40
+
41
+ it('should show placeholder when no value selected', () => {
42
+ expect(trigger.textContent).toContain('Select...');
43
+ });
44
+
45
+ it('should set aria-expanded=false initially', () => {
46
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
47
+ });
48
+
49
+ it('should open dropdown on trigger click', () => {
50
+ trigger.click();
51
+ fixture.detectChanges();
52
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
53
+ const listbox = el.querySelector('[role="listbox"]');
54
+ expect(listbox).toBeTruthy();
55
+ });
56
+
57
+ it('should show search input in dropdown', () => {
58
+ trigger.click();
59
+ fixture.detectChanges();
60
+ const searchInput = el.querySelector('input[type="text"]') as HTMLInputElement;
61
+ expect(searchInput).toBeTruthy();
62
+ expect(searchInput.getAttribute('placeholder')).toBe('Search...');
63
+ });
64
+
65
+ it('should show all options when no query', () => {
66
+ trigger.click();
67
+ fixture.detectChanges();
68
+ const options = el.querySelectorAll('[role="option"]');
69
+ expect(options.length).toBe(3);
70
+ });
71
+
72
+ it('should select option on click and show label', () => {
73
+ trigger.click();
74
+ fixture.detectChanges();
75
+
76
+ const option = el.querySelector('[role="option"]') as HTMLElement;
77
+ option.dispatchEvent(new Event('mousedown'));
78
+ fixture.detectChanges();
79
+
80
+ expect(fixture.componentInstance.value()).toBe('us');
81
+ expect(trigger.textContent).toContain('United States');
82
+ });
83
+
84
+ it('should close dropdown after selecting', () => {
85
+ trigger.click();
86
+ fixture.detectChanges();
87
+
88
+ const option = el.querySelector('[role="option"]') as HTMLElement;
89
+ option.dispatchEvent(new Event('mousedown'));
90
+ fixture.detectChanges();
91
+
92
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
93
+ });
94
+ });
95
+
96
+ @Component({
97
+ standalone: true,
98
+ imports: [ReactiveFormsModule, SnyComboboxComponent],
99
+ template: `<sny-combobox [options]="options" [formControl]="ctrl" placeholder="Select..." />`,
100
+ })
101
+ class ReactiveFormHost {
102
+ options: ComboboxOption[] = [
103
+ { value: 'us', label: 'United States' },
104
+ { value: 'uk', label: 'United Kingdom' },
105
+ { value: 'ca', label: 'Canada' },
106
+ ];
107
+ ctrl = new FormControl('');
108
+ }
109
+
110
+ describe('SnyComboboxComponent — Reactive Forms', () => {
111
+ let fixture: ComponentFixture<ReactiveFormHost>;
112
+ let el: HTMLElement;
113
+ let trigger: HTMLButtonElement;
114
+
115
+ beforeEach(async () => {
116
+ await TestBed.configureTestingModule({
117
+ imports: [ReactiveFormHost],
118
+ }).compileComponents();
119
+ fixture = TestBed.createComponent(ReactiveFormHost);
120
+ fixture.detectChanges();
121
+ el = fixture.nativeElement.querySelector('sny-combobox');
122
+ trigger = el.querySelector('button')!;
123
+ });
124
+
125
+ it('should update view when FormControl value changes (writeValue)', () => {
126
+ fixture.componentInstance.ctrl.setValue('uk');
127
+ fixture.detectChanges();
128
+ expect(trigger.textContent).toContain('United Kingdom');
129
+ });
130
+
131
+ it('should update FormControl when user interacts (onChange)', () => {
132
+ trigger.click();
133
+ fixture.detectChanges();
134
+ const option = el.querySelector('[role="option"]') as HTMLElement;
135
+ option.dispatchEvent(new Event('mousedown'));
136
+ fixture.detectChanges();
137
+ expect(fixture.componentInstance.ctrl.value).toBe('us');
138
+ });
139
+
140
+ it('should handle FormControl.disable() (setDisabledState)', () => {
141
+ fixture.componentInstance.ctrl.disable();
142
+ fixture.detectChanges();
143
+ expect(fixture.componentInstance.ctrl.disabled).toBe(true);
144
+ });
145
+
146
+ it('should mark as touched on blur (onTouched)', () => {
147
+ expect(fixture.componentInstance.ctrl.touched).toBe(false);
148
+ trigger.dispatchEvent(new Event('blur'));
149
+ expect(fixture.componentInstance.ctrl.touched).toBe(true);
150
+ });
151
+ });
@@ -0,0 +1,264 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ ElementRef,
6
+ forwardRef,
7
+ inject,
8
+ input,
9
+ model,
10
+ OnDestroy,
11
+ signal,
12
+ viewChild,
13
+ } from '@angular/core';
14
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
15
+ import { cn } from '../core/utils/cn';
16
+ import { comboboxTriggerVariants, type ComboboxSize } from './combobox.variants';
17
+
18
+ export interface ComboboxOption {
19
+ value: string;
20
+ label: string;
21
+ }
22
+
23
+ @Component({
24
+ selector: 'sny-combobox',
25
+ changeDetection: ChangeDetectionStrategy.OnPush,
26
+ host: {
27
+ class: 'relative inline-block w-full',
28
+ '(document:click)': 'onDocumentClick($event)',
29
+ },
30
+ providers: [
31
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyComboboxComponent), multi: true },
32
+ ],
33
+ template: `
34
+ <!-- Trigger button -->
35
+ <button
36
+ #triggerEl
37
+ type="button"
38
+ role="combobox"
39
+ [attr.aria-expanded]="open()"
40
+ aria-haspopup="listbox"
41
+ [class]="triggerClass()"
42
+ (click)="toggle()"
43
+ (blur)="onTouched()"
44
+ >
45
+ <span [class]="selectedLabel() ? '' : 'text-muted-foreground'">
46
+ {{ selectedLabel() || placeholder() }}
47
+ </span>
48
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
49
+ </button>
50
+
51
+ <!-- Dropdown popover -->
52
+ @if (open()) {
53
+ <div
54
+ #dropdownEl
55
+ class="fixed z-50 rounded-sm border border-border bg-popover text-popover-foreground shadow-md"
56
+ >
57
+ <!-- Search input -->
58
+ <div class="flex items-center border-b border-border px-3">
59
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2 shrink-0 opacity-50"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
60
+ <input
61
+ #searchEl
62
+ type="text"
63
+ class="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
64
+ [placeholder]="searchPlaceholder()"
65
+ [value]="query()"
66
+ (input)="onSearchInput($event)"
67
+ (keydown)="onKeydown($event)"
68
+ />
69
+ </div>
70
+
71
+ <!-- Options list -->
72
+ @if (filtered().length > 0) {
73
+ <ul role="listbox" class="max-h-60 overflow-auto p-1 sny-scrollbar">
74
+ @for (opt of filtered(); track opt.value; let i = $index) {
75
+ <li
76
+ role="option"
77
+ [id]="'sny-cb-opt-' + opt.value"
78
+ [attr.aria-selected]="value() === opt.value"
79
+ [class]="optionClass(i)"
80
+ (mousedown)="select(opt); $event.preventDefault()"
81
+ (mouseenter)="activeIndex.set(i)"
82
+ >
83
+ <svg
84
+ xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
85
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
86
+ [class]="value() === opt.value ? 'mr-2 shrink-0 opacity-100' : 'mr-2 shrink-0 opacity-0'"
87
+ ><path d="M20 6 9 17l-5-5"/></svg>
88
+ {{ opt.label }}
89
+ </li>
90
+ }
91
+ </ul>
92
+ } @else {
93
+ <div class="py-6 text-center text-sm text-muted-foreground">No results found.</div>
94
+ }
95
+ </div>
96
+ }
97
+ `,
98
+ })
99
+ export class SnyComboboxComponent implements ControlValueAccessor, OnDestroy {
100
+ readonly options = input<ComboboxOption[]>([]);
101
+ readonly placeholder = input('Select...');
102
+ readonly searchPlaceholder = input('Search...');
103
+ readonly size = input<ComboboxSize>('md');
104
+ readonly class = input<string>('');
105
+ readonly value = model<string>('');
106
+
107
+ readonly open = signal(false);
108
+ readonly query = signal('');
109
+ readonly activeIndex = signal(0);
110
+
111
+ private readonly _disabledByCva = signal(false);
112
+
113
+ private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('triggerEl');
114
+ private readonly searchRef = viewChild<ElementRef<HTMLInputElement>>('searchEl');
115
+ private readonly dropdownRef = viewChild<ElementRef<HTMLDivElement>>('dropdownEl');
116
+ private readonly elRef = inject(ElementRef);
117
+
118
+ private scrollHandler: (() => void) | null = null;
119
+ private resizeHandler: (() => void) | null = null;
120
+
121
+ private _onChange: (value: string) => void = () => {};
122
+ protected onTouched: () => void = () => {};
123
+
124
+ writeValue(val: string): void {
125
+ this.value.set(val ?? '');
126
+ }
127
+
128
+ registerOnChange(fn: (value: string) => void): void {
129
+ this._onChange = fn;
130
+ }
131
+
132
+ registerOnTouched(fn: () => void): void {
133
+ this.onTouched = fn;
134
+ }
135
+
136
+ setDisabledState(_isDisabled: boolean): void {
137
+ this._disabledByCva.set(_isDisabled);
138
+ }
139
+
140
+ readonly selectedLabel = computed(() => {
141
+ const v = this.value();
142
+ if (!v) return '';
143
+ const opt = this.options().find(o => o.value === v);
144
+ return opt?.label ?? '';
145
+ });
146
+
147
+ readonly filtered = computed(() => {
148
+ const q = this.query().toLowerCase();
149
+ if (!q) return this.options();
150
+ return this.options().filter(o => o.label.toLowerCase().includes(q));
151
+ });
152
+
153
+ protected readonly triggerClass = computed(() =>
154
+ cn(comboboxTriggerVariants({ size: this.size() }), this.class())
155
+ );
156
+
157
+ optionClass(index: number): string {
158
+ const base = 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors';
159
+ const active = index === this.activeIndex() ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50';
160
+ return cn(base, active);
161
+ }
162
+
163
+ private updateDropdownPosition(): void {
164
+ const trigger = this.triggerRef()?.nativeElement;
165
+ if (!trigger) return;
166
+ const rect = trigger.getBoundingClientRect();
167
+ const dropdown = this.dropdownRef()?.nativeElement;
168
+ if (dropdown) {
169
+ dropdown.style.top = `${rect.bottom + 4}px`;
170
+ dropdown.style.left = `${rect.left}px`;
171
+ dropdown.style.width = `${rect.width}px`;
172
+ }
173
+ }
174
+
175
+ private addGlobalListeners(): void {
176
+ this.removeGlobalListeners();
177
+ this.scrollHandler = () => {
178
+ requestAnimationFrame(() => this.updateDropdownPosition());
179
+ };
180
+ this.resizeHandler = () => {
181
+ requestAnimationFrame(() => this.updateDropdownPosition());
182
+ };
183
+ document.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true });
184
+ window.addEventListener('resize', this.resizeHandler, { passive: true });
185
+ }
186
+
187
+ private removeGlobalListeners(): void {
188
+ if (this.scrollHandler) {
189
+ document.removeEventListener('scroll', this.scrollHandler, { capture: true } as EventListenerOptions);
190
+ this.scrollHandler = null;
191
+ }
192
+ if (this.resizeHandler) {
193
+ window.removeEventListener('resize', this.resizeHandler);
194
+ this.resizeHandler = null;
195
+ }
196
+ }
197
+
198
+ ngOnDestroy(): void {
199
+ this.removeGlobalListeners();
200
+ }
201
+
202
+ toggle(): void {
203
+ if (this.open()) {
204
+ this.close();
205
+ } else {
206
+ this.updateDropdownPosition();
207
+ this.open.set(true);
208
+ this.query.set('');
209
+ this.activeIndex.set(0);
210
+ this.addGlobalListeners();
211
+ setTimeout(() => {
212
+ this.updateDropdownPosition();
213
+ this.searchRef()?.nativeElement.focus();
214
+ });
215
+ }
216
+ }
217
+
218
+ close(): void {
219
+ this.open.set(false);
220
+ this.query.set('');
221
+ this.removeGlobalListeners();
222
+ }
223
+
224
+ onSearchInput(event: Event): void {
225
+ const val = (event.target as HTMLInputElement).value;
226
+ this.query.set(val);
227
+ this.activeIndex.set(0);
228
+ }
229
+
230
+ select(opt: ComboboxOption): void {
231
+ this.value.set(opt.value);
232
+ this._onChange(opt.value);
233
+ this.close();
234
+ }
235
+
236
+ onKeydown(event: KeyboardEvent): void {
237
+ const items = this.filtered();
238
+ switch (event.key) {
239
+ case 'ArrowDown':
240
+ event.preventDefault();
241
+ this.activeIndex.update(i => Math.min(i + 1, items.length - 1));
242
+ break;
243
+ case 'ArrowUp':
244
+ event.preventDefault();
245
+ this.activeIndex.update(i => Math.max(i - 1, 0));
246
+ break;
247
+ case 'Enter':
248
+ event.preventDefault();
249
+ if (items[this.activeIndex()]) {
250
+ this.select(items[this.activeIndex()]);
251
+ }
252
+ break;
253
+ case 'Escape':
254
+ this.close();
255
+ break;
256
+ }
257
+ }
258
+
259
+ onDocumentClick(event: MouseEvent): void {
260
+ if (!this.elRef.nativeElement.contains(event.target)) {
261
+ this.close();
262
+ }
263
+ }
264
+ }
@@ -0,0 +1,19 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const comboboxTriggerVariants = cva(
4
+ 'inline-flex w-full items-center justify-between whitespace-nowrap rounded-sm border border-border bg-background px-3 py-2 text-sm ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: 'h-9 text-xs',
9
+ md: 'h-10 text-sm',
10
+ lg: 'h-11 text-base',
11
+ },
12
+ },
13
+ defaultVariants: {
14
+ size: 'md',
15
+ },
16
+ }
17
+ );
18
+
19
+ export type ComboboxSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,2 @@
1
+ export { SnyComboboxComponent, type ComboboxOption } from './combobox.component';
2
+ export { comboboxTriggerVariants, type ComboboxSize } from './combobox.variants';