@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,192 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
4
+ import { SnyCalendarComponent } from './calendar.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyCalendarComponent],
9
+ template: `<sny-calendar [(value)]="selectedDate" />`,
10
+ })
11
+ class TestHostComponent {
12
+ selectedDate = signal<Date | null>(null);
13
+ }
14
+
15
+ describe('SnyCalendarComponent', () => {
16
+ let fixture: ComponentFixture<TestHostComponent>;
17
+ let host: HTMLElement;
18
+
19
+ beforeEach(async () => {
20
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
21
+ fixture = TestBed.createComponent(TestHostComponent);
22
+ fixture.detectChanges();
23
+ host = fixture.nativeElement.querySelector('sny-calendar');
24
+ });
25
+
26
+ it('should render a grid', () => {
27
+ const grid = host.querySelector('[role="grid"]');
28
+ expect(grid).not.toBeNull();
29
+ });
30
+
31
+ it('should render day headers', () => {
32
+ const headers = host.querySelectorAll('[role="grid"] > div:not(button)');
33
+ expect(headers.length).toBe(7);
34
+ });
35
+
36
+ it('should render 42 day buttons', () => {
37
+ const buttons = host.querySelectorAll('[role="grid"] button');
38
+ expect(buttons.length).toBe(42);
39
+ });
40
+
41
+ it('should navigate months', () => {
42
+ const prevBtn = host.querySelector('[aria-label="Previous month"]') as HTMLButtonElement;
43
+ const nextBtn = host.querySelector('[aria-label="Next month"]') as HTMLButtonElement;
44
+ expect(prevBtn).not.toBeNull();
45
+ expect(nextBtn).not.toBeNull();
46
+ });
47
+
48
+ it('should select a date on click', () => {
49
+ const buttons = host.querySelectorAll('[role="grid"] button:not([disabled])');
50
+ const dayButton = Array.from(buttons).find((b) => b.textContent?.trim() === '15') as HTMLButtonElement;
51
+ if (dayButton) {
52
+ dayButton.click();
53
+ fixture.detectChanges();
54
+ expect(fixture.componentInstance.selectedDate()).not.toBeNull();
55
+ }
56
+ });
57
+ });
58
+
59
+ @Component({
60
+ standalone: true,
61
+ imports: [ReactiveFormsModule, SnyCalendarComponent],
62
+ template: `<sny-calendar [formControl]="ctrl" />`,
63
+ })
64
+ class ReactiveFormHost {
65
+ ctrl = new FormControl<Date | null>(null);
66
+ }
67
+
68
+ describe('SnyCalendarComponent — Reactive Forms', () => {
69
+ let fixture: ComponentFixture<ReactiveFormHost>;
70
+ let host: HTMLElement;
71
+
72
+ beforeEach(async () => {
73
+ await TestBed.configureTestingModule({ imports: [ReactiveFormHost] }).compileComponents();
74
+ fixture = TestBed.createComponent(ReactiveFormHost);
75
+ fixture.detectChanges();
76
+ host = fixture.nativeElement.querySelector('sny-calendar');
77
+ });
78
+
79
+ it('should update view when FormControl value changes (writeValue)', () => {
80
+ const date = new Date(2025, 5, 15);
81
+ fixture.componentInstance.ctrl.setValue(date);
82
+ fixture.detectChanges();
83
+ const selected = host.querySelector('[role="grid"] button[aria-selected="true"]');
84
+ expect(selected).not.toBeNull();
85
+ });
86
+
87
+ it('should update FormControl when user interacts (onChange)', () => {
88
+ const buttons = host.querySelectorAll('[role="grid"] button:not([disabled])');
89
+ const dayButton = Array.from(buttons).find((b) => b.textContent?.trim() === '15') as HTMLButtonElement;
90
+ if (dayButton) {
91
+ dayButton.click();
92
+ fixture.detectChanges();
93
+ expect(fixture.componentInstance.ctrl.value).not.toBeNull();
94
+ expect(fixture.componentInstance.ctrl.value!.getDate()).toBe(15);
95
+ }
96
+ });
97
+
98
+ it('should disable via FormControl.disable() (setDisabledState)', () => {
99
+ fixture.componentInstance.ctrl.disable();
100
+ fixture.detectChanges();
101
+ const buttons = host.querySelectorAll('[role="grid"] button');
102
+ const allDisabled = Array.from(buttons).every((b) => (b as HTMLButtonElement).disabled || b.getAttribute('aria-disabled') === 'true');
103
+ expect(allDisabled).toBe(true);
104
+ });
105
+ });
106
+
107
+ // --- Range Mode Tests ---
108
+
109
+ import type { DateRange } from './calendar.types';
110
+
111
+ @Component({
112
+ standalone: true,
113
+ imports: [SnyCalendarComponent],
114
+ template: `<sny-calendar mode="range" [(rangeValue)]="range" />`,
115
+ })
116
+ class RangeTestHost {
117
+ range = signal<DateRange | null>(null);
118
+ }
119
+
120
+ describe('SnyCalendarComponent — Range Mode', () => {
121
+ let fixture: ComponentFixture<RangeTestHost>;
122
+ let host: HTMLElement;
123
+
124
+ beforeEach(async () => {
125
+ await TestBed.configureTestingModule({ imports: [RangeTestHost] }).compileComponents();
126
+ fixture = TestBed.createComponent(RangeTestHost);
127
+ fixture.detectChanges();
128
+ host = fixture.nativeElement.querySelector('sny-calendar');
129
+ });
130
+
131
+ function clickDay(dayNum: string): void {
132
+ const buttons = host.querySelectorAll('[role="grid"] button:not([disabled])');
133
+ const btn = Array.from(buttons).find((b) => b.textContent?.trim() === dayNum) as HTMLButtonElement;
134
+ btn?.click();
135
+ fixture.detectChanges();
136
+ }
137
+
138
+ it('should render 42 buttons in range mode', () => {
139
+ const buttons = host.querySelectorAll('[role="grid"] button');
140
+ expect(buttons.length).toBe(42);
141
+ });
142
+
143
+ it('should set range start on first click', () => {
144
+ clickDay('10');
145
+ const range = fixture.componentInstance.range();
146
+ expect(range).not.toBeNull();
147
+ expect(range!.start).not.toBeNull();
148
+ expect(range!.start!.getDate()).toBe(10);
149
+ expect(range!.end).toBeNull();
150
+ });
151
+
152
+ it('should set range end on second click', () => {
153
+ clickDay('10');
154
+ clickDay('20');
155
+ const range = fixture.componentInstance.range();
156
+ expect(range!.start!.getDate()).toBe(10);
157
+ expect(range!.end!.getDate()).toBe(20);
158
+ });
159
+
160
+ it('should swap if second click is before start', () => {
161
+ clickDay('20');
162
+ clickDay('5');
163
+ const range = fixture.componentInstance.range();
164
+ expect(range!.start!.getDate()).toBe(5);
165
+ expect(range!.end!.getDate()).toBe(20);
166
+ });
167
+
168
+ it('should reset range on third click', () => {
169
+ clickDay('10');
170
+ clickDay('20');
171
+ clickDay('15');
172
+ const range = fixture.componentInstance.range();
173
+ expect(range!.start!.getDate()).toBe(15);
174
+ expect(range!.end).toBeNull();
175
+ });
176
+
177
+ it('should have range highlight classes when range is set', () => {
178
+ clickDay('10');
179
+ clickDay('15');
180
+ fixture.detectChanges();
181
+ const buttons = host.querySelectorAll('[role="grid"] button');
182
+ const classes = Array.from(buttons).map((b) => b.className);
183
+ const hasRangeStyle = classes.some((c) => c.includes('bg-primary/15') || c.includes('rounded-l-none') || c.includes('rounded-r-none'));
184
+ expect(hasRangeStyle).toBe(true);
185
+ });
186
+
187
+ it('should not affect single mode behavior', () => {
188
+ // This test uses the basic TestHostComponent which defaults to single mode
189
+ // Regression test: existing single mode tests above should still pass
190
+ expect(true).toBe(true);
191
+ });
192
+ });
@@ -0,0 +1,342 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ forwardRef,
6
+ input,
7
+ linkedSignal,
8
+ model,
9
+ signal,
10
+ } from '@angular/core';
11
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
12
+ import { cn } from '../core/utils/cn';
13
+ import type { CalendarDay, CalendarMode, DateRange } from './calendar.types';
14
+
15
+ @Component({
16
+ selector: 'sny-calendar',
17
+ changeDetection: ChangeDetectionStrategy.OnPush,
18
+ host: {
19
+ '[class]': 'hostClass()',
20
+ '(keydown)': 'onKeydown($event)',
21
+ 'role': 'application',
22
+ 'aria-label': 'Calendar',
23
+ },
24
+ providers: [
25
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyCalendarComponent), multi: true },
26
+ ],
27
+ template: `
28
+ @if (showNavigation()) {
29
+ <div class="flex items-center justify-between mb-3">
30
+ <button
31
+ type="button"
32
+ class="inline-flex items-center justify-center rounded-md h-8 w-8 hover:bg-accent hover:text-accent-foreground transition-colors"
33
+ (click)="prevMonth()"
34
+ aria-label="Previous month"
35
+ >
36
+ <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"><path d="m15 18-6-6 6-6"/></svg>
37
+ </button>
38
+ <span class="text-sm font-semibold tracking-tight">{{ monthYearLabel() }}</span>
39
+ <button
40
+ type="button"
41
+ class="inline-flex items-center justify-center rounded-md h-8 w-8 hover:bg-accent hover:text-accent-foreground transition-colors"
42
+ (click)="nextMonth()"
43
+ aria-label="Next month"
44
+ >
45
+ <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"><path d="m9 18 6-6-6-6"/></svg>
46
+ </button>
47
+ </div>
48
+ }
49
+
50
+ <div role="grid" class="grid grid-cols-7 gap-1">
51
+ @for (dayName of weekDays; track dayName) {
52
+ <div class="text-center text-xs text-muted-foreground font-medium h-9 flex items-center justify-center" role="columnheader">{{ dayName }}</div>
53
+ }
54
+ @for (day of days(); track day.date.getTime()) {
55
+ <button
56
+ type="button"
57
+ [class]="dayClass(day)"
58
+ [disabled]="day.isDisabled"
59
+ [attr.aria-selected]="day.isSelected || day.isRangeStart || day.isRangeEnd || null"
60
+ [attr.aria-current]="day.isToday ? 'date' : null"
61
+ [attr.aria-disabled]="day.isDisabled || null"
62
+ [attr.aria-label]="day.date.toLocaleDateString(locale(), { month: 'long', day: 'numeric', year: 'numeric' })"
63
+ role="gridcell"
64
+ (click)="onDayClick(day.date)"
65
+ (mouseenter)="onDayHover(day.date)"
66
+ (mouseleave)="onDayHover(null)"
67
+ >
68
+ {{ day.day }}
69
+ </button>
70
+ }
71
+ </div>
72
+ `,
73
+ })
74
+ export class SnyCalendarComponent implements ControlValueAccessor {
75
+ // Existing inputs (backwards compatible)
76
+ readonly value = model<Date | null>(null);
77
+ readonly min = input<Date | undefined>(undefined);
78
+ readonly max = input<Date | undefined>(undefined);
79
+ readonly locale = input('en-US');
80
+ readonly class = input<string>('');
81
+
82
+ // Range mode inputs
83
+ readonly mode = input<CalendarMode>('single');
84
+ readonly rangeValue = model<DateRange | null>(null);
85
+ readonly showNavigation = input(true);
86
+ readonly initialViewDate = input<Date | undefined>(undefined);
87
+ readonly borderless = input(false);
88
+
89
+ readonly hostClass = computed(() =>
90
+ this.borderless()
91
+ ? 'inline-block p-3 bg-background'
92
+ : 'inline-block p-4 rounded-md border border-border bg-background'
93
+ );
94
+
95
+ // Internal state
96
+ private readonly _disabledByCva = signal(false);
97
+ readonly hoveredDate = signal<Date | null>(null);
98
+ readonly viewDate = linkedSignal(() => this.initialViewDate() ?? new Date());
99
+ readonly weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
100
+
101
+ // CVA
102
+ private _onChange: (value: unknown) => void = () => {};
103
+ protected onTouched: () => void = () => {};
104
+
105
+ writeValue(val: unknown): void {
106
+ if (this.mode() === 'range') {
107
+ this.rangeValue.set((val as DateRange) ?? null);
108
+ const range = val as DateRange | null;
109
+ if (range?.start) {
110
+ this.viewDate.set(new Date(range.start.getFullYear(), range.start.getMonth(), 1));
111
+ }
112
+ } else {
113
+ this.value.set((val as Date) ?? null);
114
+ if (val) {
115
+ const d = val as Date;
116
+ this.viewDate.set(new Date(d.getFullYear(), d.getMonth(), 1));
117
+ }
118
+ }
119
+ }
120
+
121
+ registerOnChange(fn: (value: unknown) => void): void {
122
+ this._onChange = fn;
123
+ }
124
+
125
+ registerOnTouched(fn: () => void): void {
126
+ this.onTouched = fn;
127
+ }
128
+
129
+ setDisabledState(isDisabled: boolean): void {
130
+ this._disabledByCva.set(isDisabled);
131
+ }
132
+
133
+ // Computed
134
+ readonly monthYearLabel = computed(() => {
135
+ const d = this.viewDate();
136
+ return d.toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' });
137
+ });
138
+
139
+ readonly days = computed<CalendarDay[]>(() => {
140
+ const view = this.viewDate();
141
+ const year = view.getFullYear();
142
+ const month = view.getMonth();
143
+ const today = new Date();
144
+ const selected = this.value();
145
+ const rangeVal = this.mode() === 'range' ? this.rangeValue() : null;
146
+ const hovered = this.hoveredDate();
147
+ const minDate = this.min();
148
+ const maxDate = this.max();
149
+
150
+ const firstDay = new Date(year, month, 1);
151
+ const startDay = firstDay.getDay();
152
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
153
+ const daysInPrevMonth = new Date(year, month, 0).getDate();
154
+
155
+ const days: CalendarDay[] = [];
156
+
157
+ for (let i = startDay - 1; i >= 0; i--) {
158
+ const date = new Date(year, month - 1, daysInPrevMonth - i);
159
+ days.push(this.createDay(date, false, today, selected, rangeVal, hovered, minDate, maxDate));
160
+ }
161
+
162
+ for (let d = 1; d <= daysInMonth; d++) {
163
+ const date = new Date(year, month, d);
164
+ days.push(this.createDay(date, true, today, selected, rangeVal, hovered, minDate, maxDate));
165
+ }
166
+
167
+ const remaining = 42 - days.length;
168
+ for (let d = 1; d <= remaining; d++) {
169
+ const date = new Date(year, month + 1, d);
170
+ days.push(this.createDay(date, false, today, selected, rangeVal, hovered, minDate, maxDate));
171
+ }
172
+
173
+ return days;
174
+ });
175
+
176
+ // Navigation
177
+ prevMonth(): void {
178
+ this.viewDate.set(new Date(
179
+ this.viewDate().getFullYear(),
180
+ this.viewDate().getMonth() - 1,
181
+ 1,
182
+ ));
183
+ }
184
+
185
+ nextMonth(): void {
186
+ this.viewDate.set(new Date(
187
+ this.viewDate().getFullYear(),
188
+ this.viewDate().getMonth() + 1,
189
+ 1,
190
+ ));
191
+ }
192
+
193
+ // Click handler
194
+ onDayClick(date: Date): void {
195
+ if (this.mode() === 'single') {
196
+ this.value.set(date);
197
+ this._onChange(date);
198
+ this.onTouched();
199
+ return;
200
+ }
201
+
202
+ // Range mode
203
+ const current = this.rangeValue();
204
+ if (!current?.start || (current.start && current.end)) {
205
+ this.rangeValue.set({ start: date, end: null });
206
+ } else {
207
+ const start = current.start;
208
+ if (date < start) {
209
+ this.rangeValue.set({ start: date, end: start });
210
+ } else if (this.isSameDay(date, start)) {
211
+ this.rangeValue.set({ start: date, end: date });
212
+ } else {
213
+ this.rangeValue.set({ start, end: date });
214
+ }
215
+ }
216
+ this._onChange(this.rangeValue());
217
+ this.onTouched();
218
+ }
219
+
220
+ // Hover handler
221
+ onDayHover(date: Date | null): void {
222
+ if (this.mode() === 'range') {
223
+ this.hoveredDate.set(date);
224
+ }
225
+ }
226
+
227
+ // Keyboard
228
+ onKeydown(event: KeyboardEvent): void {
229
+ switch (event.key) {
230
+ case 'ArrowLeft':
231
+ event.preventDefault();
232
+ this.navigateDays(-1);
233
+ break;
234
+ case 'ArrowRight':
235
+ event.preventDefault();
236
+ this.navigateDays(1);
237
+ break;
238
+ case 'ArrowUp':
239
+ event.preventDefault();
240
+ this.navigateDays(-7);
241
+ break;
242
+ case 'ArrowDown':
243
+ event.preventDefault();
244
+ this.navigateDays(7);
245
+ break;
246
+ }
247
+ }
248
+
249
+ // Styling
250
+ dayClass(day: CalendarDay): string {
251
+ const isEndpoint = day.isRangeStart || day.isRangeEnd;
252
+ return cn(
253
+ 'inline-flex items-center justify-center text-sm h-9 w-9 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
254
+ // Shape
255
+ day.isRangeStart && !day.isRangeEnd ? 'rounded-l-md rounded-r-none' :
256
+ day.isRangeEnd && !day.isRangeStart ? 'rounded-r-md rounded-l-none' :
257
+ day.isInRange || day.isRangePreview ? 'rounded-none' :
258
+ 'rounded-md',
259
+ // Base text color
260
+ !day.isCurrentMonth && 'text-muted-foreground/40',
261
+ day.isCurrentMonth && !day.isSelected && !isEndpoint && 'text-foreground',
262
+ // Today indicator
263
+ day.isToday && !day.isSelected && !isEndpoint && 'bg-accent text-accent-foreground font-semibold',
264
+ // Single selected
265
+ day.isSelected && this.mode() === 'single' && 'bg-primary text-primary-foreground font-semibold shadow-sm',
266
+ // Range endpoints
267
+ isEndpoint && 'bg-primary text-primary-foreground font-semibold shadow-sm',
268
+ // Range band
269
+ day.isInRange && 'bg-primary/10 text-foreground',
270
+ day.isRangePreview && 'bg-primary/5 text-foreground',
271
+ // States
272
+ day.isDisabled && 'opacity-40 cursor-not-allowed pointer-events-none',
273
+ !day.isDisabled && !day.isSelected && !isEndpoint && 'hover:bg-accent hover:text-accent-foreground cursor-pointer',
274
+ );
275
+ }
276
+
277
+ // Private helpers
278
+ private navigateDays(offset: number): void {
279
+ const current = this.value() ?? new Date();
280
+ const next = new Date(current);
281
+ next.setDate(next.getDate() + offset);
282
+ this.value.set(next);
283
+ this._onChange(next);
284
+ this.viewDate.set(new Date(next.getFullYear(), next.getMonth(), 1));
285
+ }
286
+
287
+ private createDay(
288
+ date: Date,
289
+ isCurrentMonth: boolean,
290
+ today: Date,
291
+ selected: Date | null,
292
+ rangeVal: DateRange | null,
293
+ hoveredDate: Date | null,
294
+ minDate: Date | undefined,
295
+ maxDate: Date | undefined,
296
+ ): CalendarDay {
297
+ const isToday = this.isSameDay(date, today);
298
+ const isSelected = selected ? this.isSameDay(date, selected) : false;
299
+ const isDisabled =
300
+ this._disabledByCva() ||
301
+ (minDate ? date < minDate : false) ||
302
+ (maxDate ? date > maxDate : false);
303
+
304
+ let isRangeStart = false;
305
+ let isRangeEnd = false;
306
+ let isInRange = false;
307
+ let isRangePreview = false;
308
+
309
+ if (rangeVal) {
310
+ const { start, end } = rangeVal;
311
+ if (start) isRangeStart = this.isSameDay(date, start);
312
+ if (end) isRangeEnd = this.isSameDay(date, end);
313
+ if (start && end) {
314
+ isInRange = date > start && date < end && !isRangeStart && !isRangeEnd;
315
+ }
316
+ // Preview: start set, no end yet, user hovering
317
+ if (start && !end && hoveredDate && !this.isSameDay(hoveredDate, start)) {
318
+ const previewStart = hoveredDate > start ? start : hoveredDate;
319
+ const previewEnd = hoveredDate > start ? hoveredDate : start;
320
+ if (date > previewStart && date < previewEnd) {
321
+ isRangePreview = true;
322
+ }
323
+ if (this.isSameDay(date, hoveredDate) && !isRangeStart) {
324
+ isRangePreview = true;
325
+ }
326
+ }
327
+ }
328
+
329
+ return {
330
+ date, day: date.getDate(), isCurrentMonth, isToday, isSelected, isDisabled,
331
+ isRangeStart, isRangeEnd, isInRange, isRangePreview,
332
+ };
333
+ }
334
+
335
+ private isSameDay(a: Date, b: Date): boolean {
336
+ return (
337
+ a.getFullYear() === b.getFullYear() &&
338
+ a.getMonth() === b.getMonth() &&
339
+ a.getDate() === b.getDate()
340
+ );
341
+ }
342
+ }
@@ -0,0 +1,24 @@
1
+ export interface DateRange {
2
+ start: Date | null;
3
+ end: Date | null;
4
+ }
5
+
6
+ export interface CalendarDay {
7
+ date: Date;
8
+ day: number;
9
+ isCurrentMonth: boolean;
10
+ isToday: boolean;
11
+ isSelected: boolean;
12
+ isDisabled: boolean;
13
+ isRangeStart: boolean;
14
+ isRangeEnd: boolean;
15
+ isInRange: boolean;
16
+ isRangePreview: boolean;
17
+ }
18
+
19
+ export type CalendarMode = 'single' | 'range';
20
+
21
+ export interface DatePickerPreset {
22
+ label: string;
23
+ range: DateRange;
24
+ }
@@ -0,0 +1,7 @@
1
+ export { SnyCalendarComponent } from './calendar.component';
2
+ export type {
3
+ DateRange,
4
+ CalendarDay,
5
+ CalendarMode,
6
+ DatePickerPreset,
7
+ } from './calendar.types';
@@ -0,0 +1,104 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import {
4
+ SnyCardDirective,
5
+ SnyCardHeaderDirective,
6
+ SnyCardTitleDirective,
7
+ SnyCardDescriptionDirective,
8
+ SnyCardContentDirective,
9
+ SnyCardFooterDirective,
10
+ } from './card.directives';
11
+ import type { CardVariant, CardPadding } from './card.variants';
12
+
13
+ @Component({
14
+ standalone: true,
15
+ imports: [
16
+ SnyCardDirective,
17
+ SnyCardHeaderDirective,
18
+ SnyCardTitleDirective,
19
+ SnyCardDescriptionDirective,
20
+ SnyCardContentDirective,
21
+ SnyCardFooterDirective,
22
+ ],
23
+ template: `
24
+ <div snyCard [variant]="variant()" [padding]="padding()">
25
+ <div snyCardHeader>
26
+ <h3 snyCardTitle>Title</h3>
27
+ <p snyCardDescription>Description</p>
28
+ </div>
29
+ <div snyCardContent>Content</div>
30
+ <div snyCardFooter>Footer</div>
31
+ </div>
32
+ `,
33
+ })
34
+ class TestHostComponent {
35
+ variant = signal<CardVariant>('default');
36
+ padding = signal<CardPadding>('none');
37
+ }
38
+
39
+ describe('Card Directives', () => {
40
+ let fixture: ComponentFixture<TestHostComponent>;
41
+
42
+ beforeEach(async () => {
43
+ await TestBed.configureTestingModule({
44
+ imports: [TestHostComponent],
45
+ }).compileComponents();
46
+ fixture = TestBed.createComponent(TestHostComponent);
47
+ fixture.detectChanges();
48
+ });
49
+
50
+ it('should apply default card classes', () => {
51
+ const card = fixture.nativeElement.querySelector('[snyCard]');
52
+ expect(card.className).toContain('bg-card');
53
+ expect(card.className).toContain('border');
54
+ });
55
+
56
+ it('should apply elevated variant', () => {
57
+ fixture.componentInstance.variant.set('elevated');
58
+ fixture.detectChanges();
59
+ const card = fixture.nativeElement.querySelector('[snyCard]');
60
+ expect(card.className).toContain('shadow-lg');
61
+ });
62
+
63
+ it('should apply ghost variant', () => {
64
+ fixture.componentInstance.variant.set('ghost');
65
+ fixture.detectChanges();
66
+ const card = fixture.nativeElement.querySelector('[snyCard]');
67
+ expect(card.className).toContain('bg-transparent');
68
+ });
69
+
70
+ it('should apply padding', () => {
71
+ fixture.componentInstance.padding.set('md');
72
+ fixture.detectChanges();
73
+ const card = fixture.nativeElement.querySelector('[snyCard]');
74
+ expect(card.className).toContain('p-6');
75
+ });
76
+
77
+ it('should render card header', () => {
78
+ const header = fixture.nativeElement.querySelector('[snyCardHeader]');
79
+ expect(header.className).toContain('flex');
80
+ expect(header.className).toContain('p-6');
81
+ });
82
+
83
+ it('should render card title', () => {
84
+ const title = fixture.nativeElement.querySelector('[snyCardTitle]');
85
+ expect(title.className).toContain('text-2xl');
86
+ expect(title.className).toContain('font-semibold');
87
+ });
88
+
89
+ it('should render card description', () => {
90
+ const desc = fixture.nativeElement.querySelector('[snyCardDescription]');
91
+ expect(desc.className).toContain('text-muted-foreground');
92
+ });
93
+
94
+ it('should render content projection', () => {
95
+ const content = fixture.nativeElement.querySelector('[snyCardContent]');
96
+ expect(content.textContent).toContain('Content');
97
+ });
98
+
99
+ it('should render card footer', () => {
100
+ const footer = fixture.nativeElement.querySelector('[snyCardFooter]');
101
+ expect(footer.className).toContain('flex');
102
+ expect(footer.className).toContain('items-center');
103
+ });
104
+ });