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

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 +6642 -268
  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 +33 -0
  242. package/types/sonny-ui-core.d.ts +1443 -13
@@ -0,0 +1,252 @@
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 { SnyOtpInputComponent } from './otp-input.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyOtpInputComponent],
9
+ template: `
10
+ <sny-otp-input
11
+ [(value)]="otp"
12
+ [length]="length()"
13
+ [type]="type()"
14
+ [disabled]="disabled()"
15
+ [mask]="mask()"
16
+ [separator]="separator()"
17
+ [status]="status()"
18
+ [autoFocus]="false"
19
+ (completed)="lastCompleted = $event"
20
+ />
21
+ `,
22
+ })
23
+ class TestHostComponent {
24
+ otp = signal('');
25
+ length = signal(6);
26
+ type = signal<'number' | 'alphanumeric'>('number');
27
+ disabled = signal(false);
28
+ mask = signal(false);
29
+ separator = signal<number | null>(null);
30
+ status = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
31
+ lastCompleted: string | null = null;
32
+ }
33
+
34
+ describe('SnyOtpInputComponent', () => {
35
+ let fixture: ComponentFixture<TestHostComponent>;
36
+ let el: HTMLElement;
37
+
38
+ beforeEach(async () => {
39
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
40
+ fixture = TestBed.createComponent(TestHostComponent);
41
+ fixture.detectChanges();
42
+ el = fixture.nativeElement;
43
+ });
44
+
45
+ function getInputs(): HTMLInputElement[] {
46
+ return Array.from(el.querySelectorAll('input'));
47
+ }
48
+
49
+ function typeChar(input: HTMLInputElement, char: string): void {
50
+ input.value = char;
51
+ input.dispatchEvent(new Event('input', { bubbles: true }));
52
+ fixture.detectChanges();
53
+ }
54
+
55
+ it('should render N inputs based on length', () => {
56
+ expect(getInputs().length).toBe(6);
57
+ });
58
+
59
+ it('should render 4 inputs when length is 4', () => {
60
+ fixture.componentInstance.length.set(4);
61
+ fixture.detectChanges();
62
+ expect(getInputs().length).toBe(4);
63
+ });
64
+
65
+ it('should accept numbers when type is number', () => {
66
+ const inputs = getInputs();
67
+ typeChar(inputs[0], '5');
68
+ expect(fixture.componentInstance.otp()).toContain('5');
69
+ });
70
+
71
+ it('should reject letters when type is number', () => {
72
+ const inputs = getInputs();
73
+ typeChar(inputs[0], 'a');
74
+ expect(inputs[0].value).toBe('');
75
+ });
76
+
77
+ it('should accept letters when type is alphanumeric', () => {
78
+ fixture.componentInstance.type.set('alphanumeric');
79
+ fixture.detectChanges();
80
+ const inputs = getInputs();
81
+ typeChar(inputs[0], 'A');
82
+ expect(fixture.componentInstance.otp()).toContain('A');
83
+ });
84
+
85
+ it('should auto-focus next input after typing', () => {
86
+ const inputs = getInputs();
87
+ inputs[0].focus();
88
+ typeChar(inputs[0], '1');
89
+ expect(document.activeElement).toBe(inputs[1]);
90
+ });
91
+
92
+ it('should handle backspace - clear current and move back', () => {
93
+ const inputs = getInputs();
94
+ typeChar(inputs[0], '1');
95
+ typeChar(inputs[1], '2');
96
+
97
+ // Backspace on empty input[2] should move to input[1]
98
+ inputs[2].focus();
99
+ inputs[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
100
+ fixture.detectChanges();
101
+
102
+ // Should clear input[1] and focus it
103
+ expect(document.activeElement).toBe(inputs[1]);
104
+ });
105
+
106
+ it('should handle paste', () => {
107
+ const inputs = getInputs();
108
+ inputs[0].focus();
109
+
110
+ // Create a paste event compatible with test environments
111
+ const pasteEvent = new Event('paste', { bubbles: true }) as any;
112
+ pasteEvent.clipboardData = { getData: () => '123456' };
113
+ inputs[0].dispatchEvent(pasteEvent);
114
+ fixture.detectChanges();
115
+
116
+ expect(fixture.componentInstance.otp()).toBe('123456');
117
+ });
118
+
119
+ it('should emit completed when all digits filled', () => {
120
+ const inputs = getInputs();
121
+ for (let i = 0; i < 6; i++) {
122
+ typeChar(inputs[i], String(i + 1));
123
+ }
124
+ expect(fixture.componentInstance.lastCompleted).toBe('123456');
125
+ });
126
+
127
+ it('should navigate with arrow keys', () => {
128
+ const inputs = getInputs();
129
+ inputs[2].focus();
130
+
131
+ inputs[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
132
+ fixture.detectChanges();
133
+ expect(document.activeElement).toBe(inputs[1]);
134
+
135
+ inputs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
136
+ fixture.detectChanges();
137
+ expect(document.activeElement).toBe(inputs[2]);
138
+ });
139
+
140
+ it('should navigate with Home/End', () => {
141
+ const inputs = getInputs();
142
+ inputs[3].focus();
143
+
144
+ inputs[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
145
+ fixture.detectChanges();
146
+ expect(document.activeElement).toBe(inputs[0]);
147
+
148
+ inputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
149
+ fixture.detectChanges();
150
+ expect(document.activeElement).toBe(inputs[5]);
151
+ });
152
+
153
+ it('should render password inputs when mask is true', () => {
154
+ fixture.componentInstance.mask.set(true);
155
+ fixture.detectChanges();
156
+ const inputs = getInputs();
157
+ expect(inputs[0].type).toBe('password');
158
+ });
159
+
160
+ it('should render separator', () => {
161
+ fixture.componentInstance.separator.set(3);
162
+ fixture.detectChanges();
163
+ const separators = el.querySelectorAll('[aria-hidden="true"]');
164
+ expect(separators.length).toBe(1);
165
+ expect(separators[0].textContent).toContain('—');
166
+ });
167
+
168
+ it('should disable all inputs when disabled', () => {
169
+ fixture.componentInstance.disabled.set(true);
170
+ fixture.detectChanges();
171
+ const inputs = getInputs();
172
+ expect(inputs.every((i) => i.disabled)).toBe(true);
173
+ });
174
+
175
+ it('should have aria-label on each input', () => {
176
+ const inputs = getInputs();
177
+ expect(inputs[0].getAttribute('aria-label')).toBe('Digit 1 of 6');
178
+ expect(inputs[5].getAttribute('aria-label')).toBe('Digit 6 of 6');
179
+ });
180
+
181
+ it('should have autocomplete one-time-code', () => {
182
+ const inputs = getInputs();
183
+ expect(inputs[0].getAttribute('autocomplete')).toBe('one-time-code');
184
+ });
185
+
186
+ it('should disable inputs when status is loading', () => {
187
+ fixture.componentInstance.status.set('loading');
188
+ fixture.detectChanges();
189
+ const inputs = getInputs();
190
+ expect(inputs.every((i) => i.disabled)).toBe(true);
191
+ });
192
+
193
+ it('should apply success styles when status is success', () => {
194
+ fixture.componentInstance.status.set('success');
195
+ fixture.detectChanges();
196
+ const inputs = getInputs();
197
+ expect(inputs[0].className).toContain('border-green-500');
198
+ });
199
+
200
+ it('should apply error styles when status is error', () => {
201
+ fixture.componentInstance.status.set('error');
202
+ fixture.detectChanges();
203
+ const inputs = getInputs();
204
+ expect(inputs[0].className).toContain('border-destructive');
205
+ });
206
+ });
207
+
208
+ // --- Reactive Forms ---
209
+ @Component({
210
+ standalone: true,
211
+ imports: [ReactiveFormsModule, SnyOtpInputComponent],
212
+ template: `<sny-otp-input [formControl]="ctrl" [autoFocus]="false" />`,
213
+ })
214
+ class ReactiveFormHost {
215
+ ctrl = new FormControl('');
216
+ }
217
+
218
+ describe('SnyOtpInputComponent — Reactive Forms', () => {
219
+ let fixture: ComponentFixture<ReactiveFormHost>;
220
+ let el: HTMLElement;
221
+
222
+ beforeEach(async () => {
223
+ await TestBed.configureTestingModule({ imports: [ReactiveFormHost] }).compileComponents();
224
+ fixture = TestBed.createComponent(ReactiveFormHost);
225
+ fixture.detectChanges();
226
+ el = fixture.nativeElement;
227
+ });
228
+
229
+ it('should populate inputs when FormControl value is set', () => {
230
+ fixture.componentInstance.ctrl.setValue('123456');
231
+ fixture.detectChanges();
232
+ const inputs = Array.from(el.querySelectorAll('input')) as HTMLInputElement[];
233
+ expect(inputs[0].value).toBe('1');
234
+ expect(inputs[5].value).toBe('6');
235
+ });
236
+
237
+ it('should update FormControl when user types', () => {
238
+ const inputs = Array.from(el.querySelectorAll('input')) as HTMLInputElement[];
239
+ inputs[0].value = '9';
240
+ inputs[0].dispatchEvent(new Event('input', { bubbles: true }));
241
+ fixture.detectChanges();
242
+
243
+ expect(fixture.componentInstance.ctrl.value).toContain('9');
244
+ });
245
+
246
+ it('should disable via FormControl.disable()', () => {
247
+ fixture.componentInstance.ctrl.disable();
248
+ fixture.detectChanges();
249
+ const inputs = Array.from(el.querySelectorAll('input')) as HTMLInputElement[];
250
+ expect(inputs.every((i) => i.disabled)).toBe(true);
251
+ });
252
+ });
@@ -0,0 +1,274 @@
1
+ import {
2
+ afterNextRender,
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ computed,
6
+ effect,
7
+ ElementRef,
8
+ forwardRef,
9
+ input,
10
+ linkedSignal,
11
+ model,
12
+ output,
13
+ signal,
14
+ untracked,
15
+ viewChildren,
16
+ } from '@angular/core';
17
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
18
+ import { cn } from '../core/utils/cn';
19
+ import { otpCellVariants, type OtpInputSize, type OtpInputType } from './otp-input.variants';
20
+
21
+ @Component({
22
+ selector: 'sny-otp-input',
23
+ changeDetection: ChangeDetectionStrategy.OnPush,
24
+ providers: [
25
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyOtpInputComponent), multi: true },
26
+ ],
27
+ template: `
28
+ <div
29
+ role="group"
30
+ [attr.aria-label]="'OTP input, ' + length() + ' digits'"
31
+ class="flex items-center gap-2"
32
+ >
33
+ @for (digit of digits(); track $index; let i = $index) {
34
+ @if (separator() !== null && i === separator() && i > 0) {
35
+ <span class="text-muted-foreground text-lg select-none" aria-hidden="true">—</span>
36
+ }
37
+ <input
38
+ #inputEl
39
+ [type]="mask() ? 'password' : 'text'"
40
+ [inputMode]="type() === 'number' ? 'numeric' : 'text'"
41
+ [attr.pattern]="type() === 'number' ? '[0-9]' : '[a-zA-Z0-9]'"
42
+ maxlength="1"
43
+ autocomplete="one-time-code"
44
+ [value]="digit"
45
+ [placeholder]="placeholder()"
46
+ [disabled]="isDisabled()"
47
+ [class]="cellClass(i)"
48
+ [attr.aria-label]="'Digit ' + (i + 1) + ' of ' + length()"
49
+ (input)="onInput($event, i)"
50
+ (keydown)="onKeydown($event, i)"
51
+ (paste)="onPaste($event, i)"
52
+ (focus)="focusedIndex.set(i)"
53
+ (blur)="onBlur()"
54
+ />
55
+ }
56
+ </div>
57
+ `,
58
+ })
59
+ export class SnyOtpInputComponent implements ControlValueAccessor {
60
+ // Public API
61
+ readonly length = input(6);
62
+ readonly type = input<OtpInputType>('number');
63
+ readonly size = input<OtpInputSize>('md');
64
+ readonly disabled = input(false);
65
+ readonly mask = input(false);
66
+ readonly autoFocus = input(true);
67
+ readonly placeholder = input('');
68
+ readonly separator = input<number | null>(null);
69
+ readonly status = input<'idle' | 'loading' | 'success' | 'error'>('idle');
70
+ readonly value = model('');
71
+
72
+ readonly completed = output<string>();
73
+
74
+ // Internal state
75
+ readonly digits = linkedSignal<string[]>(() => Array(this.length()).fill(''));
76
+ readonly focusedIndex = signal(-1);
77
+ readonly inputRefs = viewChildren<ElementRef<HTMLInputElement>>('inputEl');
78
+
79
+ private readonly _disabledByCva = signal(false);
80
+ readonly isDisabled = computed(() => this.disabled() || this._disabledByCva() || this.status() === 'loading');
81
+
82
+ private _onChange: (value: string) => void = () => {};
83
+ private _onTouched: () => void = () => {};
84
+
85
+ // Computed
86
+ readonly fullValue = computed(() => this.digits().join(''));
87
+ readonly isComplete = computed(() => {
88
+ const d = this.digits();
89
+ return d.length === this.length() && d.every((c) => c !== '');
90
+ });
91
+
92
+ constructor() {
93
+ // Sync value → digits when value changes externally (e.g. reset)
94
+ effect(() => {
95
+ const val = this.value();
96
+ untracked(() => {
97
+ const chars = val.split('').slice(0, this.length());
98
+ const padded = [...chars, ...Array(this.length() - chars.length).fill('')];
99
+ const current = this.digits();
100
+ if (padded.join('') !== current.join('')) {
101
+ this.digits.set(padded);
102
+ }
103
+ });
104
+ });
105
+
106
+ afterNextRender(() => {
107
+ if (this.autoFocus()) {
108
+ this.focusInput(0);
109
+ }
110
+ });
111
+ }
112
+
113
+ // CVA
114
+ writeValue(val: string): void {
115
+ const str = val ?? '';
116
+ this.value.set(str);
117
+ const chars = str.split('').slice(0, this.length());
118
+ const padded = [...chars, ...Array(this.length() - chars.length).fill('')];
119
+ this.digits.set(padded);
120
+ }
121
+
122
+ registerOnChange(fn: (value: string) => void): void {
123
+ this._onChange = fn;
124
+ }
125
+
126
+ registerOnTouched(fn: () => void): void {
127
+ this._onTouched = fn;
128
+ }
129
+
130
+ setDisabledState(isDisabled: boolean): void {
131
+ this._disabledByCva.set(isDisabled);
132
+ }
133
+
134
+ // Cell class
135
+ cellClass(index: number): string {
136
+ const isFocused = this.focusedIndex() === index;
137
+ const hasValue = this.digits()[index] !== '';
138
+ const st = this.status();
139
+ return cn(
140
+ otpCellVariants({ size: this.size() }),
141
+ st === 'idle' && isFocused && 'border-primary ring-2 ring-ring',
142
+ st === 'idle' && hasValue && !isFocused && 'border-primary/50',
143
+ st === 'loading' && 'border-muted-foreground/30 opacity-70',
144
+ st === 'success' && 'border-green-500 bg-green-500/5',
145
+ st === 'error' && 'border-destructive bg-destructive/5 animate-shake',
146
+ );
147
+ }
148
+
149
+ // Input handler
150
+ onInput(event: Event, index: number): void {
151
+ const input = event.target as HTMLInputElement;
152
+ const char = input.value.slice(-1);
153
+
154
+ if (!this.isValidChar(char)) {
155
+ input.value = this.digits()[index];
156
+ return;
157
+ }
158
+
159
+ this.setDigit(index, char);
160
+
161
+ if (index < this.length() - 1) {
162
+ this.focusInput(index + 1);
163
+ }
164
+
165
+ this.emitValue();
166
+ }
167
+
168
+ // Keyboard handler
169
+ onKeydown(event: KeyboardEvent, index: number): void {
170
+ switch (event.key) {
171
+ case 'Backspace':
172
+ event.preventDefault();
173
+ if (this.digits()[index] !== '') {
174
+ this.setDigit(index, '');
175
+ this.emitValue();
176
+ } else if (index > 0) {
177
+ this.setDigit(index - 1, '');
178
+ this.focusInput(index - 1);
179
+ this.emitValue();
180
+ }
181
+ break;
182
+
183
+ case 'Delete':
184
+ event.preventDefault();
185
+ this.setDigit(index, '');
186
+ this.emitValue();
187
+ break;
188
+
189
+ case 'ArrowLeft':
190
+ event.preventDefault();
191
+ if (index > 0) this.focusInput(index - 1);
192
+ break;
193
+
194
+ case 'ArrowRight':
195
+ event.preventDefault();
196
+ if (index < this.length() - 1) this.focusInput(index + 1);
197
+ break;
198
+
199
+ case 'Home':
200
+ event.preventDefault();
201
+ this.focusInput(0);
202
+ break;
203
+
204
+ case 'End':
205
+ event.preventDefault();
206
+ this.focusInput(this.length() - 1);
207
+ break;
208
+ }
209
+ }
210
+
211
+ // Paste handler
212
+ onPaste(event: ClipboardEvent, index: number): void {
213
+ event.preventDefault();
214
+ const text = event.clipboardData?.getData('text') ?? '';
215
+ const chars = text.split('').filter((c) => this.isValidChar(c));
216
+
217
+ if (chars.length === 0) return;
218
+
219
+ const newDigits = [...this.digits()];
220
+ let lastFilledIndex = index;
221
+
222
+ for (let i = 0; i < chars.length && index + i < this.length(); i++) {
223
+ newDigits[index + i] = chars[i];
224
+ lastFilledIndex = index + i;
225
+ }
226
+
227
+ this.digits.set(newDigits);
228
+
229
+ const nextIndex = Math.min(lastFilledIndex + 1, this.length() - 1);
230
+ this.focusInput(nextIndex);
231
+ this.emitValue();
232
+ }
233
+
234
+ // Blur
235
+ onBlur(): void {
236
+ this.focusedIndex.set(-1);
237
+ this._onTouched();
238
+ }
239
+
240
+ // Helpers
241
+ private setDigit(index: number, char: string): void {
242
+ this.digits.update((d) => {
243
+ const next = [...d];
244
+ next[index] = char;
245
+ return next;
246
+ });
247
+ }
248
+
249
+ private emitValue(): void {
250
+ const val = this.fullValue();
251
+ this.value.set(val);
252
+ this._onChange(val);
253
+
254
+ if (this.isComplete()) {
255
+ this.completed.emit(val);
256
+ }
257
+ }
258
+
259
+ private focusInput(index: number): void {
260
+ const refs = this.inputRefs();
261
+ if (refs[index]) {
262
+ const el = refs[index].nativeElement;
263
+ el.focus();
264
+ el.select();
265
+ }
266
+ }
267
+
268
+ private isValidChar(char: string): boolean {
269
+ if (!char || char.length !== 1) return false;
270
+ if (this.type() === 'number') return /^[0-9]$/.test(char);
271
+ return /^[a-zA-Z0-9]$/.test(char);
272
+ }
273
+
274
+ }
@@ -0,0 +1,18 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const otpCellVariants = cva(
4
+ 'text-center font-mono font-semibold border border-border bg-background rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: 'h-9 w-9 text-sm',
9
+ md: 'h-11 w-11 text-lg',
10
+ lg: 'h-14 w-14 text-2xl',
11
+ },
12
+ },
13
+ defaultVariants: { size: 'md' },
14
+ }
15
+ );
16
+
17
+ export type OtpInputSize = 'sm' | 'md' | 'lg';
18
+ export type OtpInputType = 'number' | 'alphanumeric';
@@ -0,0 +1,6 @@
1
+ export { SnyPaginationComponent } from './pagination.component';
2
+ export {
3
+ paginationItemVariants,
4
+ type PaginationVariant,
5
+ type PaginationSize,
6
+ } from './pagination.variants';
@@ -0,0 +1,59 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyPaginationComponent } from './pagination.component';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyPaginationComponent],
8
+ template: `<sny-pagination [(currentPage)]="currentPage" [totalPages]="totalPages()" />`,
9
+ })
10
+ class TestHostComponent {
11
+ currentPage = signal(1);
12
+ totalPages = signal(10);
13
+ }
14
+
15
+ describe('SnyPaginationComponent', () => {
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-pagination');
24
+ });
25
+
26
+ it('should render with navigation role', () => {
27
+ expect(host.getAttribute('role')).toBe('navigation');
28
+ });
29
+
30
+ it('should render page buttons', () => {
31
+ const buttons = host.querySelectorAll('button');
32
+ expect(buttons.length).toBeGreaterThan(2); // at least prev + page + next
33
+ });
34
+
35
+ it('should mark current page with aria-current', () => {
36
+ const currentBtn = host.querySelector('[aria-current="page"]');
37
+ expect(currentBtn).toBeTruthy();
38
+ expect(currentBtn!.textContent?.trim()).toBe('1');
39
+ });
40
+
41
+ it('should navigate to next page', () => {
42
+ const nextBtn = host.querySelector('[aria-label="Go to next page"]') as HTMLButtonElement;
43
+ nextBtn.click();
44
+ fixture.detectChanges();
45
+ expect(fixture.componentInstance.currentPage()).toBe(2);
46
+ });
47
+
48
+ it('should disable prev on first page', () => {
49
+ const prevBtn = host.querySelector('[aria-label="Go to previous page"]') as HTMLButtonElement;
50
+ expect(prevBtn.disabled).toBe(true);
51
+ });
52
+
53
+ it('should disable next on last page', () => {
54
+ fixture.componentInstance.currentPage.set(10);
55
+ fixture.detectChanges();
56
+ const nextBtn = host.querySelector('[aria-label="Go to next page"]') as HTMLButtonElement;
57
+ expect(nextBtn.disabled).toBe(true);
58
+ });
59
+ });