@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,74 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import {
4
+ SnyFieldsetDirective,
5
+ SnyFieldsetLegendDirective,
6
+ SnyFieldsetContentDirective,
7
+ } from './fieldset.directives';
8
+ import type { FieldsetVariant } from './fieldset.variants';
9
+
10
+ @Component({
11
+ standalone: true,
12
+ imports: [SnyFieldsetDirective, SnyFieldsetLegendDirective, SnyFieldsetContentDirective],
13
+ template: `
14
+ <fieldset snyFieldset [variant]="variant()" [disabled]="disabled()">
15
+ <legend snyFieldsetLegend>Personal Info</legend>
16
+ <div snyFieldsetContent>
17
+ <input type="text" />
18
+ </div>
19
+ </fieldset>
20
+ `,
21
+ })
22
+ class TestHostComponent {
23
+ variant = signal<FieldsetVariant>('default');
24
+ disabled = signal(false);
25
+ }
26
+
27
+ describe('SnyFieldsetDirective', () => {
28
+ let fixture: ComponentFixture<TestHostComponent>;
29
+ let fieldset: HTMLFieldSetElement;
30
+
31
+ beforeEach(async () => {
32
+ await TestBed.configureTestingModule({
33
+ imports: [TestHostComponent],
34
+ }).compileComponents();
35
+ fixture = TestBed.createComponent(TestHostComponent);
36
+ fixture.detectChanges();
37
+ fieldset = fixture.nativeElement.querySelector('fieldset');
38
+ });
39
+
40
+ it('should apply default classes', () => {
41
+ expect(fieldset.className).toContain('space-y-4');
42
+ });
43
+
44
+ it('should apply bordered variant', () => {
45
+ fixture.componentInstance.variant.set('bordered');
46
+ fixture.detectChanges();
47
+ expect(fieldset.className).toContain('border');
48
+ expect(fieldset.className).toContain('rounded-lg');
49
+ });
50
+
51
+ it('should set disabled attribute', () => {
52
+ expect(fieldset.disabled).toBe(false);
53
+ fixture.componentInstance.disabled.set(true);
54
+ fixture.detectChanges();
55
+ expect(fieldset.disabled).toBe(true);
56
+ });
57
+
58
+ it('should apply legend classes', () => {
59
+ const legend = fixture.nativeElement.querySelector('legend');
60
+ expect(legend.className).toContain('font-medium');
61
+ });
62
+
63
+ it('should apply content classes', () => {
64
+ const content = fixture.nativeElement.querySelector('[snyFieldsetContent]');
65
+ expect(content.className).toContain('space-y-2');
66
+ });
67
+
68
+ it('should set aria-disabled when disabled', () => {
69
+ expect(fieldset.getAttribute('aria-disabled')).toBeNull();
70
+ fixture.componentInstance.disabled.set(true);
71
+ fixture.detectChanges();
72
+ expect(fieldset.getAttribute('aria-disabled')).toBe('true');
73
+ });
74
+ });
@@ -0,0 +1,49 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { fieldsetVariants, type FieldsetVariant } from './fieldset.variants';
4
+
5
+ @Directive({
6
+ selector: 'fieldset[snyFieldset]',
7
+ host: {
8
+ '[class]': 'computedClass()',
9
+ '[attr.disabled]': 'disabled() || null',
10
+ '[attr.aria-disabled]': 'disabled() || null',
11
+ },
12
+ })
13
+ export class SnyFieldsetDirective {
14
+ readonly variant = input<FieldsetVariant>('default');
15
+ readonly disabled = input(false);
16
+ readonly class = input<string>('');
17
+
18
+ protected readonly computedClass = computed(() =>
19
+ cn(fieldsetVariants({ variant: this.variant() }), this.class())
20
+ );
21
+ }
22
+
23
+ @Directive({
24
+ selector: 'legend[snyFieldsetLegend]',
25
+ host: {
26
+ '[class]': 'computedClass()',
27
+ },
28
+ })
29
+ export class SnyFieldsetLegendDirective {
30
+ readonly class = input<string>('');
31
+
32
+ protected readonly computedClass = computed(() =>
33
+ cn('text-sm font-medium leading-none', this.class())
34
+ );
35
+ }
36
+
37
+ @Directive({
38
+ selector: '[snyFieldsetContent]',
39
+ host: {
40
+ '[class]': 'computedClass()',
41
+ },
42
+ })
43
+ export class SnyFieldsetContentDirective {
44
+ readonly class = input<string>('');
45
+
46
+ protected readonly computedClass = computed(() =>
47
+ cn('space-y-2', this.class())
48
+ );
49
+ }
@@ -0,0 +1,15 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const fieldsetVariants = cva('space-y-4', {
4
+ variants: {
5
+ variant: {
6
+ default: '',
7
+ bordered: 'rounded-lg border border-border p-4',
8
+ },
9
+ },
10
+ defaultVariants: {
11
+ variant: 'default',
12
+ },
13
+ });
14
+
15
+ export type FieldsetVariant = 'default' | 'bordered';
@@ -0,0 +1,6 @@
1
+ export {
2
+ SnyFieldsetDirective,
3
+ SnyFieldsetLegendDirective,
4
+ SnyFieldsetContentDirective,
5
+ } from './fieldset.directives';
6
+ export { fieldsetVariants, type FieldsetVariant } from './fieldset.variants';
@@ -0,0 +1,114 @@
1
+ import { Component, signal, viewChild } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
4
+ import { SnyFileInputComponent } from './file-input.component';
5
+ import type { FileInputVariant, FileInputSize } from './file-input.variants';
6
+
7
+ @Component({
8
+ standalone: true,
9
+ imports: [SnyFileInputComponent],
10
+ template: `
11
+ <sny-file-input
12
+ [variant]="variant()"
13
+ [size]="size()"
14
+ [disabled]="disabled()"
15
+ [placeholder]="placeholder()"
16
+ [maxSize]="maxSize()"
17
+ (error)="lastError = $event"
18
+ />
19
+ `,
20
+ })
21
+ class TestHostComponent {
22
+ variant = signal<FileInputVariant>('default');
23
+ size = signal<FileInputSize>('md');
24
+ disabled = signal(false);
25
+ placeholder = signal('Choose file...');
26
+ maxSize = signal(0);
27
+ lastError = '';
28
+ fileInput = viewChild(SnyFileInputComponent);
29
+ }
30
+
31
+ describe('SnyFileInputComponent', () => {
32
+ let fixture: ComponentFixture<TestHostComponent>;
33
+ let host: HTMLElement;
34
+
35
+ beforeEach(async () => {
36
+ await TestBed.configureTestingModule({
37
+ imports: [TestHostComponent],
38
+ }).compileComponents();
39
+ fixture = TestBed.createComponent(TestHostComponent);
40
+ fixture.detectChanges();
41
+ host = fixture.nativeElement.querySelector('sny-file-input');
42
+ });
43
+
44
+ it('should show placeholder text', () => {
45
+ expect(host.textContent).toContain('Choose file...');
46
+ });
47
+
48
+ it('should apply default variant classes', () => {
49
+ const label = host.querySelector('label');
50
+ expect(label!.className).toContain('border-input');
51
+ });
52
+
53
+ it('should apply error variant classes', () => {
54
+ fixture.componentInstance.variant.set('error');
55
+ fixture.detectChanges();
56
+ const label = host.querySelector('label');
57
+ expect(label!.className).toContain('border-destructive');
58
+ });
59
+
60
+ it('should have file input with sr-only class', () => {
61
+ const input = host.querySelector('input[type="file"]') as HTMLInputElement;
62
+ expect(input).not.toBeNull();
63
+ expect(input.className).toContain('sr-only');
64
+ });
65
+
66
+ it('should disable the input', () => {
67
+ fixture.componentInstance.disabled.set(true);
68
+ fixture.detectChanges();
69
+ const input = host.querySelector('input[type="file"]') as HTMLInputElement;
70
+ expect(input.disabled).toBe(true);
71
+ });
72
+
73
+ it('should clear the selection', () => {
74
+ const comp = fixture.componentInstance.fileInput();
75
+ comp!.clear();
76
+ fixture.detectChanges();
77
+ expect(host.textContent).toContain('Choose file...');
78
+ });
79
+ });
80
+
81
+ @Component({
82
+ standalone: true,
83
+ imports: [ReactiveFormsModule, SnyFileInputComponent],
84
+ template: `<sny-file-input [formControl]="ctrl" />`,
85
+ })
86
+ class ReactiveFormHost {
87
+ ctrl = new FormControl<FileList | null>(null);
88
+ }
89
+
90
+ describe('SnyFileInputComponent — Reactive Forms', () => {
91
+ let fixture: ComponentFixture<ReactiveFormHost>;
92
+ let host: HTMLElement;
93
+
94
+ beforeEach(async () => {
95
+ await TestBed.configureTestingModule({
96
+ imports: [ReactiveFormHost],
97
+ }).compileComponents();
98
+ fixture = TestBed.createComponent(ReactiveFormHost);
99
+ fixture.detectChanges();
100
+ host = fixture.nativeElement.querySelector('sny-file-input');
101
+ });
102
+
103
+ it('should render with FormControl (writeValue)', () => {
104
+ expect(host).toBeTruthy();
105
+ expect(host.textContent).toContain('Choose file...');
106
+ });
107
+
108
+ it('should disable via FormControl.disable() (setDisabledState)', () => {
109
+ fixture.componentInstance.ctrl.disable();
110
+ fixture.detectChanges();
111
+ const input = host.querySelector('input[type="file"]') as HTMLInputElement;
112
+ expect(input.disabled).toBe(true);
113
+ });
114
+ });
@@ -0,0 +1,155 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ forwardRef,
6
+ input,
7
+ model,
8
+ output,
9
+ signal,
10
+ viewChild,
11
+ ElementRef,
12
+ } from '@angular/core';
13
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
14
+ import { cn } from '../core/utils/cn';
15
+ import {
16
+ fileInputVariants,
17
+ type FileInputVariant,
18
+ type FileInputSize,
19
+ } from './file-input.variants';
20
+
21
+ @Component({
22
+ selector: 'sny-file-input',
23
+ changeDetection: ChangeDetectionStrategy.OnPush,
24
+ host: {
25
+ '[class]': '"w-full"',
26
+ },
27
+ providers: [
28
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyFileInputComponent), multi: true },
29
+ ],
30
+ template: `
31
+ <label
32
+ [class]="labelClass()"
33
+ (dragover)="onDragOver($event)"
34
+ (dragleave)="onDragLeave($event)"
35
+ (drop)="onDrop($event)"
36
+ >
37
+ <input
38
+ #fileInput
39
+ type="file"
40
+ class="sr-only"
41
+ [accept]="accept()"
42
+ [multiple]="multiple()"
43
+ [disabled]="isDisabled()"
44
+ [attr.aria-label]="placeholder()"
45
+ [attr.aria-invalid]="variant() === 'error' || null"
46
+ (change)="onFileSelected($event)"
47
+ (blur)="onTouched()"
48
+ />
49
+ <span class="truncate text-muted-foreground">{{ fileName() }}</span>
50
+ </label>
51
+ `,
52
+ })
53
+ export class SnyFileInputComponent implements ControlValueAccessor {
54
+ readonly accept = input('');
55
+ readonly multiple = input(false);
56
+ readonly disabled = input(false);
57
+ readonly placeholder = input('Choose file...');
58
+ readonly variant = input<FileInputVariant>('default');
59
+ readonly size = input<FileInputSize>('md');
60
+ readonly maxSize = input(0);
61
+ readonly class = input<string>('');
62
+
63
+ readonly value = model<FileList | null>(null);
64
+ readonly fileChange = output<FileList>();
65
+ readonly error = output<string>();
66
+
67
+ private readonly _disabledByCva = signal(false);
68
+ protected readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
69
+
70
+ private readonly fileInputRef = viewChild<ElementRef<HTMLInputElement>>('fileInput');
71
+ readonly isDragOver = signal(false);
72
+
73
+ private _onChange: (value: FileList | null) => void = () => {};
74
+ protected onTouched: () => void = () => {};
75
+
76
+ writeValue(val: FileList | null): void {
77
+ this.value.set(val ?? null);
78
+ }
79
+
80
+ registerOnChange(fn: (value: FileList | null) => void): void {
81
+ this._onChange = fn;
82
+ }
83
+
84
+ registerOnTouched(fn: () => void): void {
85
+ this.onTouched = fn;
86
+ }
87
+
88
+ setDisabledState(isDisabled: boolean): void {
89
+ this._disabledByCva.set(isDisabled);
90
+ }
91
+
92
+ readonly fileName = computed(() => {
93
+ const files = this.value();
94
+ if (!files || files.length === 0) return this.placeholder();
95
+ if (files.length === 1) return files[0].name;
96
+ return `${files.length} files selected`;
97
+ });
98
+
99
+ protected readonly labelClass = computed(() =>
100
+ cn(
101
+ fileInputVariants({ variant: this.variant(), size: this.size() }),
102
+ this.isDragOver() && 'ring-2 ring-ring',
103
+ this.class()
104
+ )
105
+ );
106
+
107
+ onFileSelected(event: Event): void {
108
+ const input = event.target as HTMLInputElement;
109
+ if (input.files) {
110
+ this.processFiles(input.files);
111
+ }
112
+ }
113
+
114
+ onDragOver(event: DragEvent): void {
115
+ event.preventDefault();
116
+ if (!this.isDisabled()) {
117
+ this.isDragOver.set(true);
118
+ }
119
+ }
120
+
121
+ onDragLeave(event: DragEvent): void {
122
+ event.preventDefault();
123
+ this.isDragOver.set(false);
124
+ }
125
+
126
+ onDrop(event: DragEvent): void {
127
+ event.preventDefault();
128
+ this.isDragOver.set(false);
129
+ if (!this.isDisabled() && event.dataTransfer?.files) {
130
+ this.processFiles(event.dataTransfer.files);
131
+ }
132
+ }
133
+
134
+ clear(): void {
135
+ this.value.set(null);
136
+ this._onChange(null);
137
+ const inputEl = this.fileInputRef()?.nativeElement;
138
+ if (inputEl) inputEl.value = '';
139
+ }
140
+
141
+ private processFiles(files: FileList): void {
142
+ const maxSize = this.maxSize();
143
+ if (maxSize > 0) {
144
+ for (let i = 0; i < files.length; i++) {
145
+ if (files[i].size > maxSize) {
146
+ this.error.emit(`File "${files[i].name}" exceeds maximum size of ${maxSize} bytes`);
147
+ return;
148
+ }
149
+ }
150
+ }
151
+ this.value.set(files);
152
+ this._onChange(files);
153
+ this.fileChange.emit(files);
154
+ }
155
+ }
@@ -0,0 +1,25 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const fileInputVariants = cva(
4
+ 'flex w-full cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm ring-offset-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default: 'border-input',
9
+ error: 'border-destructive focus-visible:ring-destructive',
10
+ },
11
+ size: {
12
+ sm: 'h-8 text-xs',
13
+ md: 'h-10 text-sm',
14
+ lg: 'h-12 text-base',
15
+ },
16
+ },
17
+ defaultVariants: {
18
+ variant: 'default',
19
+ size: 'md',
20
+ },
21
+ }
22
+ );
23
+
24
+ export type FileInputVariant = 'default' | 'error';
25
+ export type FileInputSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,6 @@
1
+ export { SnyFileInputComponent } from './file-input.component';
2
+ export {
3
+ fileInputVariants,
4
+ type FileInputVariant,
5
+ type FileInputSize,
6
+ } from './file-input.variants';
@@ -0,0 +1,6 @@
1
+ export {
2
+ SnyIndicatorDirective,
3
+ SnyIndicatorBadgeDirective,
4
+ type IndicatorPosition,
5
+ type IndicatorVariant,
6
+ } from './indicator.directives';
@@ -0,0 +1,64 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyIndicatorDirective, SnyIndicatorBadgeDirective, type IndicatorPosition, type IndicatorVariant } from './indicator.directives';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyIndicatorDirective, SnyIndicatorBadgeDirective],
8
+ template: `
9
+ <div snyIndicator>
10
+ <span snyIndicatorBadge [position]="position()" [variant]="variant()" [ariaLabel]="badgeLabel()">5</span>
11
+ <div>Content</div>
12
+ </div>
13
+ `,
14
+ })
15
+ class TestHostComponent {
16
+ position = signal<IndicatorPosition>('top-end');
17
+ variant = signal<IndicatorVariant>('default');
18
+ badgeLabel = signal('');
19
+ }
20
+
21
+ describe('SnyIndicatorDirective', () => {
22
+ let fixture: ComponentFixture<TestHostComponent>;
23
+
24
+ beforeEach(async () => {
25
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
26
+ fixture = TestBed.createComponent(TestHostComponent);
27
+ fixture.detectChanges();
28
+ });
29
+
30
+ it('should render indicator container', () => {
31
+ const el = fixture.nativeElement.querySelector('[snyIndicator]');
32
+ expect(el.className).toContain('relative');
33
+ expect(el.className).toContain('inline-flex');
34
+ });
35
+
36
+ it('should position badge at top-end by default', () => {
37
+ const badge = fixture.nativeElement.querySelector('[snyIndicatorBadge]');
38
+ expect(badge.className).toContain('top-0');
39
+ expect(badge.className).toContain('right-0');
40
+ });
41
+
42
+ it('should change position', () => {
43
+ fixture.componentInstance.position.set('bottom-start');
44
+ fixture.detectChanges();
45
+ const badge = fixture.nativeElement.querySelector('[snyIndicatorBadge]');
46
+ expect(badge.className).toContain('bottom-0');
47
+ expect(badge.className).toContain('left-0');
48
+ });
49
+
50
+ it('should apply error variant', () => {
51
+ fixture.componentInstance.variant.set('error');
52
+ fixture.detectChanges();
53
+ const badge = fixture.nativeElement.querySelector('[snyIndicatorBadge]');
54
+ expect(badge.className).toContain('bg-destructive');
55
+ });
56
+
57
+ it('should set aria-label on badge when ariaLabel input is provided', () => {
58
+ const badge = fixture.nativeElement.querySelector('[snyIndicatorBadge]');
59
+ expect(badge.getAttribute('aria-label')).toBeNull();
60
+ fixture.componentInstance.badgeLabel.set('5 notifications');
61
+ fixture.detectChanges();
62
+ expect(badge.getAttribute('aria-label')).toBe('5 notifications');
63
+ });
64
+ });
@@ -0,0 +1,59 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ export type IndicatorPosition = 'top-start' | 'top-center' | 'top-end' | 'middle-start' | 'middle-end' | 'bottom-start' | 'bottom-center' | 'bottom-end';
5
+ export type IndicatorVariant = 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error';
6
+
7
+ const positionMap: Record<IndicatorPosition, string> = {
8
+ 'top-start': 'top-0 left-0 -translate-x-1/2 -translate-y-1/2',
9
+ 'top-center': 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2',
10
+ 'top-end': 'top-0 right-0 translate-x-1/2 -translate-y-1/2',
11
+ 'middle-start': 'top-1/2 left-0 -translate-x-1/2 -translate-y-1/2',
12
+ 'middle-end': 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2',
13
+ 'bottom-start': 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2',
14
+ 'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2',
15
+ 'bottom-end': 'bottom-0 right-0 translate-x-1/2 translate-y-1/2',
16
+ };
17
+
18
+ const variantMap: Record<IndicatorVariant, string> = {
19
+ default: 'bg-primary text-primary-foreground',
20
+ primary: 'bg-primary text-primary-foreground',
21
+ secondary: 'bg-secondary text-secondary-foreground',
22
+ success: 'bg-green-600 text-white dark:bg-green-500',
23
+ warning: 'bg-yellow-500 text-white dark:bg-yellow-400 dark:text-black',
24
+ error: 'bg-destructive text-destructive-foreground',
25
+ };
26
+
27
+ @Directive({
28
+ selector: '[snyIndicator]',
29
+ host: { '[class]': 'computedClass()' },
30
+ })
31
+ export class SnyIndicatorDirective {
32
+ readonly class = input<string>('');
33
+ protected readonly computedClass = computed(() =>
34
+ cn('relative inline-flex', this.class())
35
+ );
36
+ }
37
+
38
+ @Directive({
39
+ selector: '[snyIndicatorBadge]',
40
+ host: {
41
+ '[class]': 'computedClass()',
42
+ '[attr.aria-label]': 'ariaLabel() || null',
43
+ },
44
+ })
45
+ export class SnyIndicatorBadgeDirective {
46
+ readonly position = input<IndicatorPosition>('top-end');
47
+ readonly variant = input<IndicatorVariant>('default');
48
+ readonly ariaLabel = input<string>('');
49
+ readonly class = input<string>('');
50
+
51
+ protected readonly computedClass = computed(() =>
52
+ cn(
53
+ 'absolute z-10 inline-flex items-center justify-center rounded-full text-xs font-bold min-w-[1.25rem] h-5 px-1',
54
+ positionMap[this.position()],
55
+ variantMap[this.variant()],
56
+ this.class()
57
+ )
58
+ );
59
+ }
@@ -0,0 +1,3 @@
1
+ export { SnyInputDirective } from './input.directive';
2
+ export { SnyLabelDirective } from './label.directive';
3
+ export { inputVariants, labelVariants, type InputVariant, type InputSize } from './input.variants';
@@ -0,0 +1,103 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnyInputDirective } from './input.directive';
4
+ import { SnyLabelDirective } from './label.directive';
5
+ import type { InputVariant, InputSize } from './input.variants';
6
+
7
+ @Component({
8
+ standalone: true,
9
+ imports: [SnyInputDirective, SnyLabelDirective],
10
+ template: `
11
+ <label snyLabel [variant]="variant()">Name</label>
12
+ <input snyInput [variant]="variant()" [inputSize]="inputSize()" [ariaDescribedBy]="describedBy()" />
13
+ `,
14
+ })
15
+ class TestHostComponent {
16
+ variant = signal<InputVariant>('default');
17
+ inputSize = signal<InputSize>('md');
18
+ describedBy = signal('');
19
+ }
20
+
21
+ describe('SnyInputDirective', () => {
22
+ let fixture: ComponentFixture<TestHostComponent>;
23
+ let input: HTMLInputElement;
24
+
25
+ beforeEach(async () => {
26
+ await TestBed.configureTestingModule({
27
+ imports: [TestHostComponent],
28
+ }).compileComponents();
29
+ fixture = TestBed.createComponent(TestHostComponent);
30
+ fixture.detectChanges();
31
+ input = fixture.nativeElement.querySelector('input');
32
+ });
33
+
34
+ it('should apply default input classes', () => {
35
+ expect(input.className).toContain('border-input');
36
+ expect(input.className).toContain('h-10');
37
+ });
38
+
39
+ it('should apply error variant', () => {
40
+ fixture.componentInstance.variant.set('error');
41
+ fixture.detectChanges();
42
+ expect(input.className).toContain('border-destructive');
43
+ });
44
+
45
+ it('should set aria-invalid on error', () => {
46
+ fixture.componentInstance.variant.set('error');
47
+ fixture.detectChanges();
48
+ expect(input.getAttribute('aria-invalid')).toBe('true');
49
+ });
50
+
51
+ it('should not set aria-invalid on default', () => {
52
+ expect(input.getAttribute('aria-invalid')).toBeNull();
53
+ });
54
+
55
+ it('should apply success variant', () => {
56
+ fixture.componentInstance.variant.set('success');
57
+ fixture.detectChanges();
58
+ expect(input.className).toContain('border-green-500');
59
+ });
60
+
61
+ it('should apply sm size', () => {
62
+ fixture.componentInstance.inputSize.set('sm');
63
+ fixture.detectChanges();
64
+ expect(input.className).toContain('h-9');
65
+ });
66
+
67
+ it('should apply lg size', () => {
68
+ fixture.componentInstance.inputSize.set('lg');
69
+ fixture.detectChanges();
70
+ expect(input.className).toContain('h-11');
71
+ });
72
+
73
+ it('should set aria-describedby', () => {
74
+ fixture.componentInstance.describedBy.set('help-text');
75
+ fixture.detectChanges();
76
+ expect(input.getAttribute('aria-describedby')).toBe('help-text');
77
+ });
78
+ });
79
+
80
+ describe('SnyLabelDirective', () => {
81
+ let fixture: ComponentFixture<TestHostComponent>;
82
+ let label: HTMLLabelElement;
83
+
84
+ beforeEach(async () => {
85
+ await TestBed.configureTestingModule({
86
+ imports: [TestHostComponent],
87
+ }).compileComponents();
88
+ fixture = TestBed.createComponent(TestHostComponent);
89
+ fixture.detectChanges();
90
+ label = fixture.nativeElement.querySelector('label');
91
+ });
92
+
93
+ it('should apply label classes', () => {
94
+ expect(label.className).toContain('text-sm');
95
+ expect(label.className).toContain('font-medium');
96
+ });
97
+
98
+ it('should apply error variant to label', () => {
99
+ fixture.componentInstance.variant.set('error');
100
+ fixture.detectChanges();
101
+ expect(label.className).toContain('text-destructive');
102
+ });
103
+ });