@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,143 @@
1
+ import { Directive, ElementRef, computed, inject, input, signal, InjectionToken } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ export const SNY_ACCORDION = new InjectionToken<SnyAccordionDirective>('SnyAccordion');
5
+ export const SNY_ACCORDION_ITEM = new InjectionToken<SnyAccordionItemDirective>('SnyAccordionItem');
6
+
7
+ @Directive({
8
+ selector: '[snyAccordion]',
9
+ exportAs: 'snyAccordion',
10
+ providers: [{ provide: SNY_ACCORDION, useExisting: SnyAccordionDirective }],
11
+ host: {
12
+ '[class]': 'computedClass()',
13
+ '(keydown)': 'onKeydown($event)',
14
+ },
15
+ })
16
+ export class SnyAccordionDirective {
17
+ readonly multi = input(false);
18
+ readonly class = input<string>('');
19
+
20
+ private readonly elRef = inject(ElementRef);
21
+ private readonly _openItems = signal(new Set<string>());
22
+
23
+ protected readonly computedClass = computed(() =>
24
+ cn('divide-y divide-border', this.class())
25
+ );
26
+
27
+ isOpen(id: string): boolean {
28
+ return this._openItems().has(id);
29
+ }
30
+
31
+ toggle(id: string): void {
32
+ this._openItems.update(set => {
33
+ const next = new Set(set);
34
+ if (next.has(id)) {
35
+ next.delete(id);
36
+ } else {
37
+ if (!this.multi()) next.clear();
38
+ next.add(id);
39
+ }
40
+ return next;
41
+ });
42
+ }
43
+
44
+ onKeydown(event: KeyboardEvent): void {
45
+ const target = event.target as HTMLElement;
46
+ if (!target.hasAttribute('snyaccordiontrigger') && !target.closest('[snyAccordionTrigger]')) return;
47
+
48
+ const triggers = Array.from(
49
+ (this.elRef.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('[snyAccordionTrigger]')
50
+ );
51
+ if (triggers.length === 0) return;
52
+
53
+ const currentIndex = triggers.indexOf(target.closest('[snyAccordionTrigger]') as HTMLElement || target);
54
+ if (currentIndex === -1) return;
55
+
56
+ let nextIndex: number | null = null;
57
+ switch (event.key) {
58
+ case 'ArrowDown':
59
+ event.preventDefault();
60
+ nextIndex = (currentIndex + 1) % triggers.length;
61
+ break;
62
+ case 'ArrowUp':
63
+ event.preventDefault();
64
+ nextIndex = (currentIndex - 1 + triggers.length) % triggers.length;
65
+ break;
66
+ case 'Home':
67
+ event.preventDefault();
68
+ nextIndex = 0;
69
+ break;
70
+ case 'End':
71
+ event.preventDefault();
72
+ nextIndex = triggers.length - 1;
73
+ break;
74
+ }
75
+ if (nextIndex !== null) {
76
+ triggers[nextIndex].focus();
77
+ }
78
+ }
79
+ }
80
+
81
+ @Directive({
82
+ selector: '[snyAccordionItem]',
83
+ exportAs: 'snyAccordionItem',
84
+ providers: [{ provide: SNY_ACCORDION_ITEM, useExisting: SnyAccordionItemDirective }],
85
+ host: { '[class]': 'computedClass()' },
86
+ })
87
+ export class SnyAccordionItemDirective {
88
+ readonly value = input.required<string>();
89
+ readonly class = input<string>('');
90
+ private readonly accordion = inject(SNY_ACCORDION);
91
+
92
+ readonly isOpen = computed(() => this.accordion.isOpen(this.value()));
93
+
94
+ protected readonly computedClass = computed(() =>
95
+ cn('', this.class())
96
+ );
97
+
98
+ toggle(): void {
99
+ this.accordion.toggle(this.value());
100
+ }
101
+ }
102
+
103
+ @Directive({
104
+ selector: '[snyAccordionTrigger]',
105
+ host: {
106
+ '[class]': 'computedClass()',
107
+ '[attr.aria-expanded]': 'item.isOpen()',
108
+ 'tabindex': '0',
109
+ '(click)': 'item.toggle()',
110
+ },
111
+ })
112
+ export class SnyAccordionTriggerDirective {
113
+ readonly class = input<string>('');
114
+ readonly item = inject(SNY_ACCORDION_ITEM);
115
+
116
+ protected readonly computedClass = computed(() =>
117
+ cn(
118
+ 'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline cursor-pointer [&>svg]:transition-transform',
119
+ this.item.isOpen() && '[&>svg]:rotate-180',
120
+ this.class()
121
+ )
122
+ );
123
+ }
124
+
125
+ @Directive({
126
+ selector: '[snyAccordionContent]',
127
+ host: {
128
+ '[class]': 'computedClass()',
129
+ role: 'region',
130
+ },
131
+ })
132
+ export class SnyAccordionContentDirective {
133
+ readonly class = input<string>('');
134
+ readonly item = inject(SNY_ACCORDION_ITEM);
135
+
136
+ protected readonly computedClass = computed(() =>
137
+ cn(
138
+ 'grid transition-all duration-200',
139
+ this.item.isOpen() ? 'grid-rows-[1fr] opacity-100 pb-4' : 'grid-rows-[0fr] opacity-0 overflow-hidden',
140
+ this.class()
141
+ )
142
+ );
143
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ SnyAccordionDirective,
3
+ SnyAccordionItemDirective,
4
+ SnyAccordionTriggerDirective,
5
+ SnyAccordionContentDirective,
6
+ SNY_ACCORDION,
7
+ SNY_ACCORDION_ITEM,
8
+ } from './accordion.directives';
@@ -0,0 +1,154 @@
1
+ import { Component, signal, viewChild } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyAlertDirective, SnyAlertTitleDirective, SnyAlertDescriptionDirective } from './alert.directives';
4
+ import type { AlertVariant } from './alert.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyAlertDirective, SnyAlertTitleDirective, SnyAlertDescriptionDirective],
9
+ template: `
10
+ <div snyAlert [variant]="variant()" [dismissible]="dismissible()">
11
+ <h5 snyAlertTitle>{{ title() }}</h5>
12
+ <p snyAlertDescription>{{ description() }}</p>
13
+ </div>
14
+ `,
15
+ })
16
+ class TestHostComponent {
17
+ variant = signal<AlertVariant>('default');
18
+ dismissible = signal(false);
19
+ title = signal('Test Title');
20
+ description = signal('Test Description');
21
+ alert = viewChild(SnyAlertDirective);
22
+ }
23
+
24
+ describe('SnyAlertDirective', () => {
25
+ let fixture: ComponentFixture<TestHostComponent>;
26
+ let el: HTMLElement;
27
+
28
+ beforeEach(async () => {
29
+ await TestBed.configureTestingModule({
30
+ imports: [TestHostComponent],
31
+ }).compileComponents();
32
+ fixture = TestBed.createComponent(TestHostComponent);
33
+ fixture.detectChanges();
34
+ el = fixture.nativeElement.querySelector('[snyAlert]');
35
+ });
36
+
37
+ it('should apply default variant classes', () => {
38
+ expect(el.className).toContain('bg-background');
39
+ expect(el.className).toContain('rounded-lg');
40
+ });
41
+
42
+ it('should apply destructive variant classes', () => {
43
+ fixture.componentInstance.variant.set('destructive');
44
+ fixture.detectChanges();
45
+ expect(el.className).toContain('text-destructive');
46
+ });
47
+
48
+ it('should apply success variant classes', () => {
49
+ fixture.componentInstance.variant.set('success');
50
+ fixture.detectChanges();
51
+ expect(el.className).toContain('text-green-700');
52
+ });
53
+
54
+ it('should apply warning variant classes', () => {
55
+ fixture.componentInstance.variant.set('warning');
56
+ fixture.detectChanges();
57
+ expect(el.className).toContain('text-yellow-700');
58
+ });
59
+
60
+ it('should apply info variant classes', () => {
61
+ fixture.componentInstance.variant.set('info');
62
+ fixture.detectChanges();
63
+ expect(el.className).toContain('text-blue-700');
64
+ });
65
+
66
+ it('should set role="status" for default and success', () => {
67
+ expect(el.getAttribute('role')).toBe('status');
68
+ fixture.componentInstance.variant.set('success');
69
+ fixture.detectChanges();
70
+ expect(el.getAttribute('role')).toBe('status');
71
+ });
72
+
73
+ it('should set role="alert" for destructive, warning, info', () => {
74
+ fixture.componentInstance.variant.set('destructive');
75
+ fixture.detectChanges();
76
+ expect(el.getAttribute('role')).toBe('alert');
77
+
78
+ fixture.componentInstance.variant.set('warning');
79
+ fixture.detectChanges();
80
+ expect(el.getAttribute('role')).toBe('alert');
81
+
82
+ fixture.componentInstance.variant.set('info');
83
+ fixture.detectChanges();
84
+ expect(el.getAttribute('role')).toBe('alert');
85
+ });
86
+
87
+ it('should set aria-live="assertive" for destructive/warning', () => {
88
+ fixture.componentInstance.variant.set('destructive');
89
+ fixture.detectChanges();
90
+ expect(el.getAttribute('aria-live')).toBe('assertive');
91
+
92
+ fixture.componentInstance.variant.set('warning');
93
+ fixture.detectChanges();
94
+ expect(el.getAttribute('aria-live')).toBe('assertive');
95
+ });
96
+
97
+ it('should set aria-live="polite" for default/success/info', () => {
98
+ expect(el.getAttribute('aria-live')).toBe('polite');
99
+
100
+ fixture.componentInstance.variant.set('success');
101
+ fixture.detectChanges();
102
+ expect(el.getAttribute('aria-live')).toBe('polite');
103
+
104
+ fixture.componentInstance.variant.set('info');
105
+ fixture.detectChanges();
106
+ expect(el.getAttribute('aria-live')).toBe('polite');
107
+ });
108
+
109
+ it('should be visible by default', () => {
110
+ expect(el.style.display).not.toBe('none');
111
+ });
112
+
113
+ it('should hide when dismiss() is called', () => {
114
+ const alert = fixture.componentInstance.alert();
115
+ alert!.dismiss();
116
+ fixture.detectChanges();
117
+ expect(el.style.display).toBe('none');
118
+ });
119
+ });
120
+
121
+ describe('SnyAlertTitleDirective', () => {
122
+ let fixture: ComponentFixture<TestHostComponent>;
123
+
124
+ beforeEach(async () => {
125
+ await TestBed.configureTestingModule({
126
+ imports: [TestHostComponent],
127
+ }).compileComponents();
128
+ fixture = TestBed.createComponent(TestHostComponent);
129
+ fixture.detectChanges();
130
+ });
131
+
132
+ it('should apply title classes', () => {
133
+ const title = fixture.nativeElement.querySelector('[snyAlertTitle]');
134
+ expect(title.className).toContain('font-medium');
135
+ expect(title.className).toContain('tracking-tight');
136
+ });
137
+ });
138
+
139
+ describe('SnyAlertDescriptionDirective', () => {
140
+ let fixture: ComponentFixture<TestHostComponent>;
141
+
142
+ beforeEach(async () => {
143
+ await TestBed.configureTestingModule({
144
+ imports: [TestHostComponent],
145
+ }).compileComponents();
146
+ fixture = TestBed.createComponent(TestHostComponent);
147
+ fixture.detectChanges();
148
+ });
149
+
150
+ it('should apply description classes', () => {
151
+ const desc = fixture.nativeElement.querySelector('[snyAlertDescription]');
152
+ expect(desc.className).toContain('text-sm');
153
+ });
154
+ });
@@ -0,0 +1,67 @@
1
+ import { Directive, computed, input, signal } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { alertVariants, type AlertVariant } from './alert.variants';
4
+
5
+ @Directive({
6
+ selector: '[snyAlert]',
7
+ exportAs: 'snyAlert',
8
+ host: {
9
+ '[class]': 'computedClass()',
10
+ '[attr.role]': 'role()',
11
+ '[attr.aria-live]': 'ariaLive()',
12
+ '[style.display]': 'visible() ? null : "none"',
13
+ },
14
+ })
15
+ export class SnyAlertDirective {
16
+ readonly variant = input<AlertVariant>('default');
17
+ readonly dismissible = input(false);
18
+ readonly class = input<string>('');
19
+
20
+ readonly visible = signal(true);
21
+
22
+ protected readonly role = computed(() => {
23
+ const v = this.variant();
24
+ return v === 'destructive' || v === 'warning' || v === 'info' ? 'alert' : 'status';
25
+ });
26
+
27
+ protected readonly ariaLive = computed(() => {
28
+ const v = this.variant();
29
+ return v === 'destructive' || v === 'warning' ? 'assertive' : 'polite';
30
+ });
31
+
32
+ protected readonly computedClass = computed(() =>
33
+ cn(alertVariants({ variant: this.variant() }), this.class())
34
+ );
35
+
36
+ dismiss(): void {
37
+ this.visible.set(false);
38
+ }
39
+ }
40
+
41
+ @Directive({
42
+ selector: '[snyAlertTitle]',
43
+ host: {
44
+ '[class]': 'computedClass()',
45
+ },
46
+ })
47
+ export class SnyAlertTitleDirective {
48
+ readonly class = input<string>('');
49
+
50
+ protected readonly computedClass = computed(() =>
51
+ cn('mb-1 font-medium leading-none tracking-tight', this.class())
52
+ );
53
+ }
54
+
55
+ @Directive({
56
+ selector: '[snyAlertDescription]',
57
+ host: {
58
+ '[class]': 'computedClass()',
59
+ },
60
+ })
61
+ export class SnyAlertDescriptionDirective {
62
+ readonly class = input<string>('');
63
+
64
+ protected readonly computedClass = computed(() =>
65
+ cn('text-sm [&_p]:leading-relaxed', this.class())
66
+ );
67
+ }
@@ -0,0 +1,25 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const alertVariants = cva(
4
+ 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default:
9
+ 'bg-background text-foreground border-border',
10
+ destructive:
11
+ 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive bg-destructive/10',
12
+ success:
13
+ 'border-green-500/50 text-green-700 dark:text-green-400 dark:border-green-500 [&>svg]:text-green-600 bg-green-50 dark:bg-green-950/30',
14
+ warning:
15
+ 'border-yellow-500/50 text-yellow-700 dark:text-yellow-400 dark:border-yellow-500 [&>svg]:text-yellow-600 bg-yellow-50 dark:bg-yellow-950/30',
16
+ info: 'border-blue-500/50 text-blue-700 dark:text-blue-400 dark:border-blue-500 [&>svg]:text-blue-600 bg-blue-50 dark:bg-blue-950/30',
17
+ },
18
+ },
19
+ defaultVariants: {
20
+ variant: 'default',
21
+ },
22
+ }
23
+ );
24
+
25
+ export type AlertVariant = 'default' | 'destructive' | 'success' | 'warning' | 'info';
@@ -0,0 +1,6 @@
1
+ export {
2
+ SnyAlertDirective,
3
+ SnyAlertTitleDirective,
4
+ SnyAlertDescriptionDirective,
5
+ } from './alert.directives';
6
+ export { alertVariants, type AlertVariant } from './alert.variants';
@@ -0,0 +1,75 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnyAvatarComponent } from './avatar.component';
4
+ import type { AvatarSize, AvatarVariant } from './avatar.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyAvatarComponent],
9
+ template: `<sny-avatar [src]="src()" [alt]="alt()" [fallback]="fallback()" [size]="size()" [variant]="variant()" />`,
10
+ })
11
+ class TestHostComponent {
12
+ src = signal('');
13
+ alt = signal('John Doe');
14
+ fallback = signal('');
15
+ size = signal<AvatarSize>('md');
16
+ variant = signal<AvatarVariant>('circle');
17
+ }
18
+
19
+ describe('SnyAvatarComponent', () => {
20
+ let fixture: ComponentFixture<TestHostComponent>;
21
+ let el: HTMLElement;
22
+
23
+ beforeEach(async () => {
24
+ await TestBed.configureTestingModule({
25
+ imports: [TestHostComponent],
26
+ }).compileComponents();
27
+
28
+ fixture = TestBed.createComponent(TestHostComponent);
29
+ fixture.detectChanges();
30
+ el = fixture.nativeElement.querySelector('sny-avatar');
31
+ });
32
+
33
+ it('should show fallback text when no src', () => {
34
+ const span = el.querySelector('span');
35
+ expect(span?.textContent?.trim()).toBe('JD');
36
+ });
37
+
38
+ it('should show custom fallback', () => {
39
+ fixture.componentInstance.fallback.set('AB');
40
+ fixture.detectChanges();
41
+ const span = el.querySelector('span');
42
+ expect(span?.textContent?.trim()).toBe('AB');
43
+ });
44
+
45
+ it('should show image when src is provided', () => {
46
+ fixture.componentInstance.src.set('https://example.com/avatar.png');
47
+ fixture.detectChanges();
48
+ const img = el.querySelector('img');
49
+ expect(img).toBeTruthy();
50
+ expect(img?.getAttribute('src')).toBe('https://example.com/avatar.png');
51
+ });
52
+
53
+ it('should apply circle variant by default', () => {
54
+ expect(el.className).toContain('rounded-full');
55
+ });
56
+
57
+ it('should apply rounded variant', () => {
58
+ fixture.componentInstance.variant.set('rounded');
59
+ fixture.detectChanges();
60
+ expect(el.className).toContain('rounded-md');
61
+ });
62
+
63
+ it('should apply size classes', () => {
64
+ fixture.componentInstance.size.set('lg');
65
+ fixture.detectChanges();
66
+ expect(el.className).toContain('h-12');
67
+ expect(el.className).toContain('w-12');
68
+ });
69
+
70
+ it('should apply xl size', () => {
71
+ fixture.componentInstance.size.set('xl');
72
+ fixture.detectChanges();
73
+ expect(el.className).toContain('h-16');
74
+ });
75
+ });
@@ -0,0 +1,43 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { avatarVariants, type AvatarSize, type AvatarVariant } from './avatar.variants';
4
+
5
+ @Component({
6
+ selector: 'sny-avatar',
7
+ changeDetection: ChangeDetectionStrategy.OnPush,
8
+ host: { '[class]': 'computedClass()' },
9
+ template: `
10
+ @if (src() && !error()) {
11
+ <img
12
+ [src]="src()"
13
+ [alt]="alt()"
14
+ class="aspect-square h-full w-full object-cover"
15
+ (error)="error.set(true)"
16
+ />
17
+ } @else {
18
+ <span class="font-medium text-muted-foreground">{{ fallbackText() }}</span>
19
+ }
20
+ `,
21
+ })
22
+ export class SnyAvatarComponent {
23
+ readonly src = input<string>('');
24
+ readonly alt = input<string>('');
25
+ readonly fallback = input<string>('');
26
+ readonly size = input<AvatarSize>('md');
27
+ readonly variant = input<AvatarVariant>('circle');
28
+ readonly class = input<string>('');
29
+
30
+ readonly error = signal(false);
31
+
32
+ protected readonly fallbackText = computed(() => {
33
+ const fb = this.fallback();
34
+ if (fb) return fb;
35
+ const a = this.alt();
36
+ if (a) return a.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
37
+ return '?';
38
+ });
39
+
40
+ protected readonly computedClass = computed(() =>
41
+ cn(avatarVariants({ size: this.size(), variant: this.variant() }), this.class())
42
+ );
43
+ }
@@ -0,0 +1,26 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const avatarVariants = cva(
4
+ 'relative inline-flex shrink-0 items-center justify-center overflow-hidden bg-muted',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: 'h-8 w-8 text-xs',
9
+ md: 'h-10 w-10 text-sm',
10
+ lg: 'h-12 w-12 text-base',
11
+ xl: 'h-16 w-16 text-lg',
12
+ },
13
+ variant: {
14
+ circle: 'rounded-full',
15
+ rounded: 'rounded-md',
16
+ },
17
+ },
18
+ defaultVariants: {
19
+ size: 'md',
20
+ variant: 'circle',
21
+ },
22
+ }
23
+ );
24
+
25
+ export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl';
26
+ export type AvatarVariant = 'circle' | 'rounded';
@@ -0,0 +1,2 @@
1
+ export { SnyAvatarComponent } from './avatar.component';
2
+ export { avatarVariants, type AvatarSize, type AvatarVariant } from './avatar.variants';
@@ -0,0 +1,74 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyAvatarGroupComponent, type AvatarGroupItem } from './avatar-group.component';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyAvatarGroupComponent],
8
+ template: `<sny-avatar-group [items]="items()" [max]="max()" [size]="size()" />`,
9
+ })
10
+ class TestHost {
11
+ items = signal<AvatarGroupItem[]>([
12
+ { src: 'a.jpg', alt: 'Alice' },
13
+ { src: 'b.jpg', alt: 'Bob' },
14
+ { src: 'c.jpg', alt: 'Carol' },
15
+ { src: 'd.jpg', alt: 'David' },
16
+ { src: 'e.jpg', alt: 'Eve' },
17
+ ]);
18
+ max = signal(3);
19
+ size = signal<'sm' | 'md' | 'lg'>('md');
20
+ }
21
+
22
+ describe('SnyAvatarGroupComponent', () => {
23
+ let fixture: ComponentFixture<TestHost>;
24
+ let el: HTMLElement;
25
+
26
+ beforeEach(async () => {
27
+ await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
28
+ fixture = TestBed.createComponent(TestHost);
29
+ fixture.detectChanges();
30
+ el = fixture.nativeElement;
31
+ });
32
+
33
+ it('should render max avatars', () => {
34
+ const imgs = el.querySelectorAll('img');
35
+ expect(imgs.length).toBe(3);
36
+ });
37
+
38
+ it('should show overflow counter', () => {
39
+ const counter = el.querySelector('[title="2 more"]');
40
+ expect(counter).not.toBeNull();
41
+ expect(counter?.textContent).toContain('+2');
42
+ });
43
+
44
+ it('should not show counter when no overflow', () => {
45
+ fixture.componentInstance.max.set(5);
46
+ fixture.detectChanges();
47
+ const counter = el.querySelector('[title]');
48
+ expect(counter).toBeNull();
49
+ });
50
+
51
+ it('should render all when max >= items', () => {
52
+ fixture.componentInstance.max.set(10);
53
+ fixture.detectChanges();
54
+ const imgs = el.querySelectorAll('img');
55
+ expect(imgs.length).toBe(5);
56
+ });
57
+
58
+ it('should render fallback initials when no src', () => {
59
+ fixture.componentInstance.items.set([
60
+ { fallback: 'AB' },
61
+ { fallback: 'CD' },
62
+ ]);
63
+ fixture.componentInstance.max.set(2);
64
+ fixture.detectChanges();
65
+ const fallbacks = el.querySelectorAll('.bg-muted');
66
+ expect(fallbacks.length).toBe(2);
67
+ expect(fallbacks[0].textContent).toContain('AB');
68
+ });
69
+
70
+ it('should have aria-label on group', () => {
71
+ const group = el.querySelector('[role="group"]');
72
+ expect(group?.getAttribute('aria-label')).toBe('Group of 5 users');
73
+ });
74
+ });