@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,63 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnySkeletonDirective } from './skeleton.directive';
4
+ import type { SkeletonVariant, SkeletonSize } from './skeleton.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnySkeletonDirective],
9
+ template: `<div snySkeleton [variant]="variant()" [size]="size()"></div>`,
10
+ })
11
+ class TestHostComponent {
12
+ variant = signal<SkeletonVariant>('line');
13
+ size = signal<SkeletonSize>('md');
14
+ }
15
+
16
+ describe('SnySkeletonDirective', () => {
17
+ let fixture: ComponentFixture<TestHostComponent>;
18
+ let el: HTMLElement;
19
+
20
+ beforeEach(async () => {
21
+ await TestBed.configureTestingModule({
22
+ imports: [TestHostComponent],
23
+ }).compileComponents();
24
+
25
+ fixture = TestBed.createComponent(TestHostComponent);
26
+ fixture.detectChanges();
27
+ el = fixture.nativeElement.querySelector('div');
28
+ });
29
+
30
+ it('should apply default classes', () => {
31
+ expect(el.className).toContain('animate-pulse');
32
+ expect(el.className).toContain('bg-muted');
33
+ expect(el.className).toContain('rounded-sm');
34
+ expect(el.className).toContain('h-6');
35
+ });
36
+
37
+ it('should apply circular variant', () => {
38
+ fixture.componentInstance.variant.set('circular');
39
+ fixture.detectChanges();
40
+ expect(el.className).toContain('rounded-full');
41
+ expect(el.className).toContain('aspect-square');
42
+ });
43
+
44
+ it('should apply sm size', () => {
45
+ fixture.componentInstance.size.set('sm');
46
+ fixture.detectChanges();
47
+ expect(el.className).toContain('h-4');
48
+ });
49
+
50
+ it('should apply xl size', () => {
51
+ fixture.componentInstance.size.set('xl');
52
+ fixture.detectChanges();
53
+ expect(el.className).toContain('h-12');
54
+ });
55
+
56
+ it('should have aria-busy="true"', () => {
57
+ expect(el.getAttribute('aria-busy')).toBe('true');
58
+ });
59
+
60
+ it('should have aria-hidden="true"', () => {
61
+ expect(el.getAttribute('aria-hidden')).toBe('true');
62
+ });
63
+ });
@@ -0,0 +1,21 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { skeletonVariants, type SkeletonVariant, type SkeletonSize } from './skeleton.variants';
4
+
5
+ @Directive({
6
+ selector: '[snySkeleton]',
7
+ host: {
8
+ '[class]': 'computedClass()',
9
+ 'aria-busy': 'true',
10
+ 'aria-hidden': 'true',
11
+ },
12
+ })
13
+ export class SnySkeletonDirective {
14
+ readonly variant = input<SkeletonVariant>('line');
15
+ readonly size = input<SkeletonSize>('md');
16
+ readonly class = input<string>('');
17
+
18
+ protected readonly computedClass = computed(() =>
19
+ cn(skeletonVariants({ variant: this.variant(), size: this.size() }), this.class())
20
+ );
21
+ }
@@ -0,0 +1,27 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const skeletonVariants = cva(
4
+ 'animate-pulse bg-muted block',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ line: 'rounded-sm',
9
+ circular: 'rounded-full aspect-square',
10
+ rounded: 'rounded-lg',
11
+ },
12
+ size: {
13
+ sm: 'h-4',
14
+ md: 'h-6',
15
+ lg: 'h-8',
16
+ xl: 'h-12',
17
+ },
18
+ },
19
+ defaultVariants: {
20
+ variant: 'line',
21
+ size: 'md',
22
+ },
23
+ }
24
+ );
25
+
26
+ export type SkeletonVariant = 'line' | 'circular' | 'rounded';
27
+ export type SkeletonSize = 'sm' | 'md' | 'lg' | 'xl';
@@ -0,0 +1,2 @@
1
+ export { SnySliderComponent } from './slider.component';
2
+ export { sliderTrackVariants, type SliderSize } from './slider.variants';
@@ -0,0 +1,104 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
4
+ import { SnySliderComponent } from './slider.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnySliderComponent],
9
+ template: `<sny-slider [(value)]="value" [min]="min()" [max]="max()" [step]="step()" [disabled]="disabled()" />`,
10
+ })
11
+ class TestHostComponent {
12
+ value = signal(50);
13
+ min = signal(0);
14
+ max = signal(100);
15
+ step = signal(1);
16
+ disabled = signal(false);
17
+ }
18
+
19
+ describe('SnySliderComponent', () => {
20
+ let fixture: ComponentFixture<TestHostComponent>;
21
+ let thumb: HTMLButtonElement;
22
+
23
+ beforeEach(async () => {
24
+ await TestBed.configureTestingModule({
25
+ imports: [TestHostComponent],
26
+ }).compileComponents();
27
+
28
+ fixture = TestBed.createComponent(TestHostComponent);
29
+ fixture.detectChanges();
30
+ thumb = fixture.nativeElement.querySelector('button[role="slider"]');
31
+ });
32
+
33
+ it('should have slider role', () => {
34
+ expect(thumb.getAttribute('role')).toBe('slider');
35
+ });
36
+
37
+ it('should set aria values', () => {
38
+ expect(thumb.getAttribute('aria-valuemin')).toBe('0');
39
+ expect(thumb.getAttribute('aria-valuemax')).toBe('100');
40
+ expect(thumb.getAttribute('aria-valuenow')).toBe('50');
41
+ });
42
+
43
+ it('should increment with ArrowRight', () => {
44
+ const host = fixture.nativeElement.querySelector('sny-slider');
45
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
46
+ fixture.detectChanges();
47
+ expect(thumb.getAttribute('aria-valuenow')).toBe('51');
48
+ });
49
+
50
+ it('should decrement with ArrowLeft', () => {
51
+ const host = fixture.nativeElement.querySelector('sny-slider');
52
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
53
+ fixture.detectChanges();
54
+ expect(thumb.getAttribute('aria-valuenow')).toBe('49');
55
+ });
56
+ });
57
+
58
+ @Component({
59
+ standalone: true,
60
+ imports: [ReactiveFormsModule, SnySliderComponent],
61
+ template: `<sny-slider [formControl]="ctrl" />`,
62
+ })
63
+ class ReactiveFormHost {
64
+ ctrl = new FormControl(50);
65
+ }
66
+
67
+ describe('SnySliderComponent — Reactive Forms', () => {
68
+ let fixture: ComponentFixture<ReactiveFormHost>;
69
+ let thumb: HTMLButtonElement;
70
+
71
+ beforeEach(async () => {
72
+ await TestBed.configureTestingModule({
73
+ imports: [ReactiveFormHost],
74
+ }).compileComponents();
75
+ fixture = TestBed.createComponent(ReactiveFormHost);
76
+ fixture.detectChanges();
77
+ thumb = fixture.nativeElement.querySelector('button[role="slider"]');
78
+ });
79
+
80
+ it('should update view when FormControl value changes (writeValue)', () => {
81
+ fixture.componentInstance.ctrl.setValue(75);
82
+ fixture.detectChanges();
83
+ expect(thumb.getAttribute('aria-valuenow')).toBe('75');
84
+ });
85
+
86
+ it('should update FormControl when user interacts (onChange)', () => {
87
+ const host = fixture.nativeElement.querySelector('sny-slider');
88
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
89
+ fixture.detectChanges();
90
+ expect(fixture.componentInstance.ctrl.value).toBe(51);
91
+ });
92
+
93
+ it('should disable via FormControl.disable() (setDisabledState)', () => {
94
+ fixture.componentInstance.ctrl.disable();
95
+ fixture.detectChanges();
96
+ expect(thumb.disabled).toBe(true);
97
+ });
98
+
99
+ it('should mark as touched on blur (onTouched)', () => {
100
+ expect(fixture.componentInstance.ctrl.touched).toBe(false);
101
+ thumb.dispatchEvent(new Event('blur'));
102
+ expect(fixture.componentInstance.ctrl.touched).toBe(true);
103
+ });
104
+ });
@@ -0,0 +1,181 @@
1
+ import { ChangeDetectionStrategy, Component, computed, ElementRef, forwardRef, input, model, OnDestroy, signal, viewChild } from '@angular/core';
2
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
3
+ import { cn } from '../core/utils/cn';
4
+ import { sliderTrackVariants, sliderThumbSize, type SliderSize } from './slider.variants';
5
+
6
+ @Component({
7
+ selector: 'sny-slider',
8
+ changeDetection: ChangeDetectionStrategy.OnPush,
9
+ host: {
10
+ class: 'block',
11
+ '(keydown)': 'onKeydown($event)',
12
+ },
13
+ providers: [
14
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnySliderComponent), multi: true },
15
+ ],
16
+ template: `
17
+ <div
18
+ #trackEl
19
+ [class]="trackClass()"
20
+ (mousedown)="onTrackMousedown($event)"
21
+ (touchstart)="onTrackTouchstart($event)"
22
+ >
23
+ <div class="absolute h-full rounded-full bg-primary" [style.width.%]="percentage()"></div>
24
+ <button
25
+ type="button"
26
+ role="slider"
27
+ [attr.aria-valuemin]="min()"
28
+ [attr.aria-valuemax]="max()"
29
+ [attr.aria-valuenow]="value()"
30
+ [disabled]="isDisabled()"
31
+ [class]="thumbClass()"
32
+ [style.left.%]="percentage()"
33
+ tabindex="0"
34
+ (blur)="onTouched()"
35
+ ></button>
36
+ </div>
37
+ `,
38
+ })
39
+ export class SnySliderComponent implements ControlValueAccessor, OnDestroy {
40
+ readonly value = model(0);
41
+ readonly min = input(0);
42
+ readonly max = input(100);
43
+ readonly step = input(1);
44
+ readonly disabled = input(false);
45
+ readonly size = input<SliderSize>('md');
46
+ readonly class = input<string>('');
47
+
48
+ private readonly _disabledByCva = signal(false);
49
+ protected readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
50
+
51
+ private readonly trackRef = viewChild<ElementRef<HTMLDivElement>>('trackEl');
52
+ private moveHandler: ((e: MouseEvent | TouchEvent) => void) | null = null;
53
+ private upHandler: (() => void) | null = null;
54
+
55
+ private _onChange: (value: number) => void = () => {};
56
+ protected onTouched: () => void = () => {};
57
+
58
+ writeValue(val: number): void {
59
+ this.value.set(val ?? 0);
60
+ }
61
+
62
+ registerOnChange(fn: (value: number) => void): void {
63
+ this._onChange = fn;
64
+ }
65
+
66
+ registerOnTouched(fn: () => void): void {
67
+ this.onTouched = fn;
68
+ }
69
+
70
+ setDisabledState(isDisabled: boolean): void {
71
+ this._disabledByCva.set(isDisabled);
72
+ }
73
+
74
+ protected readonly percentage = computed(() => {
75
+ const range = this.max() - this.min();
76
+ if (range <= 0) return 0;
77
+ return ((this.value() - this.min()) / range) * 100;
78
+ });
79
+
80
+ protected readonly trackClass = computed(() =>
81
+ cn(sliderTrackVariants({ size: this.size() }), this.isDisabled() && 'opacity-50 cursor-not-allowed', this.class())
82
+ );
83
+
84
+ protected readonly thumbClass = computed(() =>
85
+ cn(
86
+ 'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 block rounded-full border-2 border-primary bg-background shadow ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
87
+ sliderThumbSize[this.size()]
88
+ )
89
+ );
90
+
91
+ private updateValueFromPosition(clientX: number): void {
92
+ if (this.isDisabled()) return;
93
+ const track = this.trackRef()?.nativeElement;
94
+ if (!track) return;
95
+ const rect = track.getBoundingClientRect();
96
+ const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
97
+ const raw = this.min() + percent * (this.max() - this.min());
98
+ const stepped = Math.round(raw / this.step()) * this.step();
99
+ const clamped = Math.max(this.min(), Math.min(this.max(), stepped));
100
+ this.value.set(clamped);
101
+ this._onChange(clamped);
102
+ }
103
+
104
+ onTrackMousedown(event: MouseEvent): void {
105
+ if (this.isDisabled()) return;
106
+ event.preventDefault();
107
+ this.updateValueFromPosition(event.clientX);
108
+ this.moveHandler = (e: MouseEvent | TouchEvent) => {
109
+ const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
110
+ this.updateValueFromPosition(clientX);
111
+ };
112
+ this.upHandler = () => {
113
+ this.onTouched();
114
+ this.removeListeners();
115
+ };
116
+ document.addEventListener('mousemove', this.moveHandler as EventListener);
117
+ document.addEventListener('mouseup', this.upHandler);
118
+ }
119
+
120
+ onTrackTouchstart(event: TouchEvent): void {
121
+ if (this.isDisabled()) return;
122
+ this.updateValueFromPosition(event.touches[0].clientX);
123
+ this.moveHandler = (e: MouseEvent | TouchEvent) => {
124
+ const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
125
+ this.updateValueFromPosition(clientX);
126
+ };
127
+ this.upHandler = () => {
128
+ this.onTouched();
129
+ this.removeListeners();
130
+ };
131
+ document.addEventListener('touchmove', this.moveHandler as EventListener, { passive: true });
132
+ document.addEventListener('touchend', this.upHandler);
133
+ }
134
+
135
+ onKeydown(event: KeyboardEvent): void {
136
+ if (this.isDisabled()) return;
137
+ const step = this.step();
138
+ let newVal: number | undefined;
139
+ switch (event.key) {
140
+ case 'ArrowRight':
141
+ case 'ArrowUp':
142
+ event.preventDefault();
143
+ newVal = Math.min(this.max(), this.value() + step);
144
+ break;
145
+ case 'ArrowLeft':
146
+ case 'ArrowDown':
147
+ event.preventDefault();
148
+ newVal = Math.max(this.min(), this.value() - step);
149
+ break;
150
+ case 'Home':
151
+ event.preventDefault();
152
+ newVal = this.min();
153
+ break;
154
+ case 'End':
155
+ event.preventDefault();
156
+ newVal = this.max();
157
+ break;
158
+ }
159
+ if (newVal !== undefined) {
160
+ this.value.set(newVal);
161
+ this._onChange(newVal);
162
+ }
163
+ }
164
+
165
+ private removeListeners(): void {
166
+ if (this.moveHandler) {
167
+ document.removeEventListener('mousemove', this.moveHandler as EventListener);
168
+ document.removeEventListener('touchmove', this.moveHandler as EventListener);
169
+ this.moveHandler = null;
170
+ }
171
+ if (this.upHandler) {
172
+ document.removeEventListener('mouseup', this.upHandler);
173
+ document.removeEventListener('touchend', this.upHandler);
174
+ this.upHandler = null;
175
+ }
176
+ }
177
+
178
+ ngOnDestroy(): void {
179
+ this.removeListeners();
180
+ }
181
+ }
@@ -0,0 +1,25 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const sliderTrackVariants = cva(
4
+ 'relative w-full rounded-full bg-secondary cursor-pointer',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: 'h-1.5',
9
+ md: 'h-2',
10
+ lg: 'h-3',
11
+ },
12
+ },
13
+ defaultVariants: {
14
+ size: 'md',
15
+ },
16
+ }
17
+ );
18
+
19
+ export const sliderThumbSize: Record<string, string> = {
20
+ sm: 'h-4 w-4',
21
+ md: 'h-5 w-5',
22
+ lg: 'h-6 w-6',
23
+ };
24
+
25
+ export type SliderSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,8 @@
1
+ export {
2
+ SnyStatDirective,
3
+ SnyStatTitleDirective,
4
+ SnyStatValueDirective,
5
+ SnyStatDescriptionDirective,
6
+ SnyStatFigureDirective,
7
+ type StatDescriptionVariant,
8
+ } from './stat.directives';
@@ -0,0 +1,60 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyStatDirective, SnyStatTitleDirective, SnyStatValueDirective, SnyStatDescriptionDirective, type StatDescriptionVariant } from './stat.directives';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyStatDirective, SnyStatTitleDirective, SnyStatValueDirective, SnyStatDescriptionDirective],
8
+ template: `
9
+ <div snyStat>
10
+ <div snyStatTitle>Total Revenue</div>
11
+ <div snyStatValue>$45,231</div>
12
+ <div snyStatDescription [variant]="descVariant()">+20.1% from last month</div>
13
+ </div>
14
+ `,
15
+ })
16
+ class TestHostComponent {
17
+ descVariant = signal<StatDescriptionVariant>('default');
18
+ }
19
+
20
+ describe('SnyStatDirective', () => {
21
+ let fixture: ComponentFixture<TestHostComponent>;
22
+
23
+ beforeEach(async () => {
24
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
25
+ fixture = TestBed.createComponent(TestHostComponent);
26
+ fixture.detectChanges();
27
+ });
28
+
29
+ it('should render stat sections', () => {
30
+ expect(fixture.nativeElement.querySelector('[snyStat]')).toBeTruthy();
31
+ expect(fixture.nativeElement.querySelector('[snyStatTitle]')).toBeTruthy();
32
+ expect(fixture.nativeElement.querySelector('[snyStatValue]')).toBeTruthy();
33
+ expect(fixture.nativeElement.querySelector('[snyStatDescription]')).toBeTruthy();
34
+ });
35
+
36
+ it('should apply success variant to description', () => {
37
+ fixture.componentInstance.descVariant.set('success');
38
+ fixture.detectChanges();
39
+ const desc = fixture.nativeElement.querySelector('[snyStatDescription]');
40
+ expect(desc.className).toContain('text-green-600');
41
+ });
42
+
43
+ it('should apply error variant to description', () => {
44
+ fixture.componentInstance.descVariant.set('error');
45
+ fixture.detectChanges();
46
+ const desc = fixture.nativeElement.querySelector('[snyStatDescription]');
47
+ expect(desc.className).toContain('text-destructive');
48
+ });
49
+
50
+ it('should have auto-generated id on title', () => {
51
+ const title = fixture.nativeElement.querySelector('[snyStatTitle]');
52
+ expect(title.id).toContain('sny-stat-title-');
53
+ });
54
+
55
+ it('should set aria-labelledby on value pointing to title id', () => {
56
+ const title = fixture.nativeElement.querySelector('[snyStatTitle]');
57
+ const value = fixture.nativeElement.querySelector('[snyStatValue]');
58
+ expect(value.getAttribute('aria-labelledby')).toBe(title.id);
59
+ });
60
+ });
@@ -0,0 +1,79 @@
1
+ import { Directive, InjectionToken, computed, inject, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ export const SNY_STAT = new InjectionToken<SnyStatDirective>('SnyStat');
5
+
6
+ let statIdCounter = 0;
7
+
8
+ @Directive({
9
+ selector: '[snyStat]',
10
+ providers: [{ provide: SNY_STAT, useExisting: SnyStatDirective }],
11
+ host: { '[class]': 'computedClass()' },
12
+ })
13
+ export class SnyStatDirective {
14
+ readonly class = input<string>('');
15
+ readonly titleId = `sny-stat-title-${++statIdCounter}`;
16
+ protected readonly computedClass = computed(() =>
17
+ cn('flex flex-col gap-0.5', this.class())
18
+ );
19
+ }
20
+
21
+ @Directive({
22
+ selector: '[snyStatTitle]',
23
+ host: {
24
+ '[class]': 'computedClass()',
25
+ '[id]': 'stat.titleId',
26
+ },
27
+ })
28
+ export class SnyStatTitleDirective {
29
+ readonly class = input<string>('');
30
+ readonly stat = inject(SNY_STAT);
31
+ protected readonly computedClass = computed(() =>
32
+ cn('text-sm text-muted-foreground', this.class())
33
+ );
34
+ }
35
+
36
+ @Directive({
37
+ selector: '[snyStatValue]',
38
+ host: {
39
+ '[class]': 'computedClass()',
40
+ '[attr.aria-labelledby]': 'stat.titleId',
41
+ },
42
+ })
43
+ export class SnyStatValueDirective {
44
+ readonly class = input<string>('');
45
+ readonly stat = inject(SNY_STAT);
46
+ protected readonly computedClass = computed(() =>
47
+ cn('text-2xl font-bold', this.class())
48
+ );
49
+ }
50
+
51
+ export type StatDescriptionVariant = 'default' | 'success' | 'error';
52
+
53
+ @Directive({
54
+ selector: '[snyStatDescription]',
55
+ host: { '[class]': 'computedClass()' },
56
+ })
57
+ export class SnyStatDescriptionDirective {
58
+ readonly variant = input<StatDescriptionVariant>('default');
59
+ readonly class = input<string>('');
60
+ protected readonly computedClass = computed(() => {
61
+ const v = this.variant();
62
+ const variantClass =
63
+ v === 'success' ? 'text-green-600 dark:text-green-400' :
64
+ v === 'error' ? 'text-destructive' :
65
+ 'text-muted-foreground';
66
+ return cn('text-xs', variantClass, this.class());
67
+ });
68
+ }
69
+
70
+ @Directive({
71
+ selector: '[snyStatFigure]',
72
+ host: { '[class]': 'computedClass()' },
73
+ })
74
+ export class SnyStatFigureDirective {
75
+ readonly class = input<string>('');
76
+ protected readonly computedClass = computed(() =>
77
+ cn('text-muted-foreground', this.class())
78
+ );
79
+ }
@@ -0,0 +1,2 @@
1
+ export { SnyStatusDirective } from './status.directive';
2
+ export { statusVariants, type StatusVariant, type StatusSize } from './status.variants';
@@ -0,0 +1,43 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyStatusDirective } from './status.directive';
4
+ import type { StatusVariant } from './status.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyStatusDirective],
9
+ template: `<span snyStatus [variant]="variant()" [pulse]="pulse()"></span>`,
10
+ })
11
+ class TestHostComponent {
12
+ variant = signal<StatusVariant>('default');
13
+ pulse = signal(false);
14
+ }
15
+
16
+ describe('SnyStatusDirective', () => {
17
+ let fixture: ComponentFixture<TestHostComponent>;
18
+ let el: HTMLElement;
19
+
20
+ beforeEach(async () => {
21
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
22
+ fixture = TestBed.createComponent(TestHostComponent);
23
+ fixture.detectChanges();
24
+ el = fixture.nativeElement.querySelector('[snyStatus]');
25
+ });
26
+
27
+ it('should have status role', () => {
28
+ expect(el.getAttribute('role')).toBe('status');
29
+ });
30
+
31
+ it('should apply success variant', () => {
32
+ fixture.componentInstance.variant.set('success');
33
+ fixture.detectChanges();
34
+ expect(el.className).toContain('bg-green-500');
35
+ expect(el.getAttribute('aria-label')).toBe('Online');
36
+ });
37
+
38
+ it('should apply pulse animation', () => {
39
+ fixture.componentInstance.pulse.set(true);
40
+ fixture.detectChanges();
41
+ expect(el.className).toContain('animate-pulse');
42
+ });
43
+ });
@@ -0,0 +1,37 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { statusVariants, type StatusVariant, type StatusSize } from './status.variants';
4
+
5
+ const variantLabels: Record<StatusVariant, string> = {
6
+ default: 'Active',
7
+ success: 'Online',
8
+ warning: 'Away',
9
+ error: 'Error',
10
+ info: 'Info',
11
+ neutral: 'Offline',
12
+ };
13
+
14
+ @Directive({
15
+ selector: '[snyStatus]',
16
+ host: {
17
+ 'role': 'status',
18
+ '[attr.aria-label]': 'ariaLabel()',
19
+ '[class]': 'computedClass()',
20
+ },
21
+ })
22
+ export class SnyStatusDirective {
23
+ readonly variant = input<StatusVariant>('default');
24
+ readonly size = input<StatusSize>('md');
25
+ readonly pulse = input(false);
26
+ readonly class = input<string>('');
27
+
28
+ protected readonly ariaLabel = computed(() => variantLabels[this.variant()]);
29
+
30
+ protected readonly computedClass = computed(() =>
31
+ cn(
32
+ statusVariants({ variant: this.variant(), size: this.size() }),
33
+ this.pulse() && 'animate-pulse',
34
+ this.class()
35
+ )
36
+ );
37
+ }