@sonny-ui/core 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (198) hide show
  1. package/README.md +101 -32
  2. package/fesm2022/sonny-ui-core.mjs +3031 -42
  3. package/fesm2022/sonny-ui-core.mjs.map +1 -1
  4. package/package.json +8 -5
  5. package/schematics/ng-add/schema.json +1 -1
  6. package/schematics/ng-generate/component/index.js +182 -1
  7. package/schematics/ng-generate/component/schema.json +2 -2
  8. package/src/lib/accordion/accordion.directives.spec.ts +173 -0
  9. package/src/lib/accordion/accordion.directives.ts +147 -0
  10. package/src/lib/accordion/index.ts +8 -0
  11. package/src/lib/alert/alert.directives.spec.ts +154 -0
  12. package/src/lib/alert/alert.directives.ts +70 -0
  13. package/src/lib/alert/alert.variants.ts +25 -0
  14. package/src/lib/alert/index.ts +6 -0
  15. package/src/lib/avatar/avatar.component.spec.ts +75 -0
  16. package/src/lib/avatar/avatar.component.ts +44 -0
  17. package/src/lib/avatar/avatar.variants.ts +26 -0
  18. package/src/lib/avatar/index.ts +2 -0
  19. package/src/lib/badge/badge.directive.spec.ts +74 -0
  20. package/src/lib/badge/badge.directive.ts +18 -0
  21. package/src/lib/badge/badge.variants.ts +29 -0
  22. package/src/lib/badge/index.ts +2 -0
  23. package/src/lib/breadcrumb/breadcrumb.directives.spec.ts +80 -0
  24. package/src/lib/breadcrumb/breadcrumb.directives.ts +84 -0
  25. package/src/lib/breadcrumb/index.ts +8 -0
  26. package/src/lib/button/button.directive.spec.ts +92 -0
  27. package/src/lib/button/button.directive.ts +29 -0
  28. package/src/lib/button/button.variants.ts +30 -0
  29. package/src/lib/button/index.ts +2 -0
  30. package/src/lib/button-group/button-group.directive.spec.ts +46 -0
  31. package/src/lib/button-group/button-group.directive.ts +20 -0
  32. package/src/lib/button-group/button-group.variants.ts +18 -0
  33. package/src/lib/button-group/index.ts +2 -0
  34. package/src/lib/calendar/calendar.component.spec.ts +105 -0
  35. package/src/lib/calendar/calendar.component.ts +231 -0
  36. package/src/lib/calendar/index.ts +1 -0
  37. package/src/lib/card/card.directives.spec.ts +104 -0
  38. package/src/lib/card/card.directives.ts +78 -0
  39. package/src/lib/card/card.variants.ts +28 -0
  40. package/src/lib/card/index.ts +9 -0
  41. package/src/lib/carousel/carousel.directives.spec.ts +85 -0
  42. package/src/lib/carousel/carousel.directives.ts +164 -0
  43. package/src/lib/carousel/index.ts +8 -0
  44. package/src/lib/chat-bubble/chat-bubble.directives.spec.ts +52 -0
  45. package/src/lib/chat-bubble/chat-bubble.directives.ts +102 -0
  46. package/src/lib/chat-bubble/index.ts +11 -0
  47. package/src/lib/checkbox/checkbox.directive.spec.ts +57 -0
  48. package/src/lib/checkbox/checkbox.directive.ts +17 -0
  49. package/src/lib/checkbox/checkbox.variants.ts +19 -0
  50. package/src/lib/checkbox/index.ts +2 -0
  51. package/src/lib/combobox/combobox.component.spec.ts +151 -0
  52. package/src/lib/combobox/combobox.component.ts +279 -0
  53. package/src/lib/combobox/combobox.variants.ts +19 -0
  54. package/src/lib/combobox/index.ts +2 -0
  55. package/src/lib/diff/diff.component.spec.ts +47 -0
  56. package/src/lib/diff/diff.component.ts +83 -0
  57. package/src/lib/diff/index.ts +1 -0
  58. package/src/lib/divider/divider.component.spec.ts +48 -0
  59. package/src/lib/divider/divider.component.ts +52 -0
  60. package/src/lib/divider/divider.variants.ts +22 -0
  61. package/src/lib/divider/index.ts +2 -0
  62. package/src/lib/dock/dock.directives.spec.ts +85 -0
  63. package/src/lib/dock/dock.directives.ts +83 -0
  64. package/src/lib/dock/index.ts +1 -0
  65. package/src/lib/drawer/drawer.directives.spec.ts +62 -0
  66. package/src/lib/drawer/drawer.directives.ts +83 -0
  67. package/src/lib/drawer/index.ts +8 -0
  68. package/src/lib/dropdown/dropdown.directives.spec.ts +106 -0
  69. package/src/lib/dropdown/dropdown.directives.ts +143 -0
  70. package/src/lib/dropdown/dropdown.variants.ts +27 -0
  71. package/src/lib/dropdown/index.ts +15 -0
  72. package/src/lib/fab/fab.directives.spec.ts +60 -0
  73. package/src/lib/fab/fab.directives.ts +80 -0
  74. package/src/lib/fab/index.ts +8 -0
  75. package/src/lib/fieldset/fieldset.directives.spec.ts +74 -0
  76. package/src/lib/fieldset/fieldset.directives.ts +52 -0
  77. package/src/lib/fieldset/fieldset.variants.ts +15 -0
  78. package/src/lib/fieldset/index.ts +6 -0
  79. package/src/lib/file-input/file-input.component.spec.ts +114 -0
  80. package/src/lib/file-input/file-input.component.ts +168 -0
  81. package/src/lib/file-input/file-input.variants.ts +25 -0
  82. package/src/lib/file-input/index.ts +6 -0
  83. package/src/lib/indicator/index.ts +6 -0
  84. package/src/lib/indicator/indicator.directives.spec.ts +64 -0
  85. package/src/lib/indicator/indicator.directives.ts +61 -0
  86. package/src/lib/input/index.ts +3 -0
  87. package/src/lib/input/input.directive.spec.ts +103 -0
  88. package/src/lib/input/input.directive.ts +26 -0
  89. package/src/lib/input/input.variants.ts +42 -0
  90. package/src/lib/input/label.directive.ts +17 -0
  91. package/src/lib/kbd/index.ts +2 -0
  92. package/src/lib/kbd/kbd.directive.spec.ts +42 -0
  93. package/src/lib/kbd/kbd.directive.ts +19 -0
  94. package/src/lib/kbd/kbd.variants.ts +19 -0
  95. package/src/lib/link/index.ts +2 -0
  96. package/src/lib/link/link.directive.spec.ts +41 -0
  97. package/src/lib/link/link.directive.ts +19 -0
  98. package/src/lib/link/link.variants.ts +20 -0
  99. package/src/lib/list/index.ts +8 -0
  100. package/src/lib/list/list.directives.spec.ts +65 -0
  101. package/src/lib/list/list.directives.ts +86 -0
  102. package/src/lib/loader/index.ts +2 -0
  103. package/src/lib/loader/loader.component.spec.ts +58 -0
  104. package/src/lib/loader/loader.component.ts +48 -0
  105. package/src/lib/loader/loader.variants.ts +21 -0
  106. package/src/lib/modal/dialog-ref.ts +19 -0
  107. package/src/lib/modal/dialog.directives.ts +90 -0
  108. package/src/lib/modal/dialog.service.spec.ts +52 -0
  109. package/src/lib/modal/dialog.service.ts +61 -0
  110. package/src/lib/modal/dialog.types.ts +16 -0
  111. package/src/lib/modal/index.ts +11 -0
  112. package/src/lib/navbar/index.ts +7 -0
  113. package/src/lib/navbar/navbar.directives.spec.ts +59 -0
  114. package/src/lib/navbar/navbar.directives.ts +61 -0
  115. package/src/lib/pagination/index.ts +6 -0
  116. package/src/lib/pagination/pagination.component.spec.ts +59 -0
  117. package/src/lib/pagination/pagination.component.ts +144 -0
  118. package/src/lib/pagination/pagination.variants.ts +31 -0
  119. package/src/lib/progress/index.ts +7 -0
  120. package/src/lib/progress/progress.component.spec.ts +117 -0
  121. package/src/lib/progress/progress.component.ts +65 -0
  122. package/src/lib/progress/progress.variants.ts +43 -0
  123. package/src/lib/radial-progress/index.ts +5 -0
  124. package/src/lib/radial-progress/radial-progress.component.spec.ts +41 -0
  125. package/src/lib/radial-progress/radial-progress.component.ts +71 -0
  126. package/src/lib/radio/index.ts +2 -0
  127. package/src/lib/radio/radio.directive.spec.ts +46 -0
  128. package/src/lib/radio/radio.directive.ts +17 -0
  129. package/src/lib/radio/radio.variants.ts +19 -0
  130. package/src/lib/rating/index.ts +2 -0
  131. package/src/lib/rating/rating.component.spec.ts +157 -0
  132. package/src/lib/rating/rating.component.ts +171 -0
  133. package/src/lib/rating/rating.variants.ts +20 -0
  134. package/src/lib/select/index.ts +2 -0
  135. package/src/lib/select/select.component.spec.ts +112 -0
  136. package/src/lib/select/select.component.ts +250 -0
  137. package/src/lib/select/select.variants.ts +19 -0
  138. package/src/lib/sheet/index.ts +10 -0
  139. package/src/lib/sheet/sheet-ref.ts +18 -0
  140. package/src/lib/sheet/sheet.component.spec.ts +67 -0
  141. package/src/lib/sheet/sheet.directives.ts +75 -0
  142. package/src/lib/sheet/sheet.service.ts +100 -0
  143. package/src/lib/sheet/sheet.types.ts +23 -0
  144. package/src/lib/skeleton/index.ts +2 -0
  145. package/src/lib/skeleton/skeleton.directive.spec.ts +63 -0
  146. package/src/lib/skeleton/skeleton.directive.ts +22 -0
  147. package/src/lib/skeleton/skeleton.variants.ts +27 -0
  148. package/src/lib/slider/index.ts +2 -0
  149. package/src/lib/slider/slider.component.spec.ts +104 -0
  150. package/src/lib/slider/slider.component.ts +188 -0
  151. package/src/lib/slider/slider.variants.ts +25 -0
  152. package/src/lib/stat/index.ts +8 -0
  153. package/src/lib/stat/stat.directives.spec.ts +60 -0
  154. package/src/lib/stat/stat.directives.ts +84 -0
  155. package/src/lib/status/index.ts +2 -0
  156. package/src/lib/status/status.directive.spec.ts +43 -0
  157. package/src/lib/status/status.directive.ts +38 -0
  158. package/src/lib/status/status.variants.ts +26 -0
  159. package/src/lib/steps/index.ts +8 -0
  160. package/src/lib/steps/steps.directives.spec.ts +52 -0
  161. package/src/lib/steps/steps.directives.ts +80 -0
  162. package/src/lib/switch/index.ts +2 -0
  163. package/src/lib/switch/switch.component.spec.ts +98 -0
  164. package/src/lib/switch/switch.component.ts +84 -0
  165. package/src/lib/switch/switch.variants.ts +31 -0
  166. package/src/lib/table/index.ts +12 -0
  167. package/src/lib/table/table.directives.spec.ts +111 -0
  168. package/src/lib/table/table.directives.ts +134 -0
  169. package/src/lib/table/table.variants.ts +36 -0
  170. package/src/lib/tabs/index.ts +8 -0
  171. package/src/lib/tabs/tabs.directives.spec.ts +136 -0
  172. package/src/lib/tabs/tabs.directives.ts +130 -0
  173. package/src/lib/tabs/tabs.variants.ts +17 -0
  174. package/src/lib/textarea/index.ts +7 -0
  175. package/src/lib/textarea/textarea.directive.spec.ts +84 -0
  176. package/src/lib/textarea/textarea.directive.ts +72 -0
  177. package/src/lib/textarea/textarea.variants.ts +34 -0
  178. package/src/lib/timeline/index.ts +11 -0
  179. package/src/lib/timeline/timeline.directives.spec.ts +55 -0
  180. package/src/lib/timeline/timeline.directives.ts +90 -0
  181. package/src/lib/toast/index.ts +3 -0
  182. package/src/lib/toast/toast.service.spec.ts +71 -0
  183. package/src/lib/toast/toast.service.ts +60 -0
  184. package/src/lib/toast/toast.variants.ts +38 -0
  185. package/src/lib/toast/toaster.component.spec.ts +38 -0
  186. package/src/lib/toast/toaster.component.ts +82 -0
  187. package/src/lib/toggle/index.ts +2 -0
  188. package/src/lib/toggle/toggle.directive.spec.ts +100 -0
  189. package/src/lib/toggle/toggle.directive.ts +73 -0
  190. package/src/lib/toggle/toggle.variants.ts +25 -0
  191. package/src/lib/tooltip/index.ts +2 -0
  192. package/src/lib/tooltip/tooltip.directive.spec.ts +113 -0
  193. package/src/lib/tooltip/tooltip.directive.ts +131 -0
  194. package/src/lib/tooltip/tooltip.variants.ts +20 -0
  195. package/src/lib/validator/index.ts +5 -0
  196. package/src/lib/validator/validator.directives.spec.ts +47 -0
  197. package/src/lib/validator/validator.directives.ts +52 -0
  198. package/types/sonny-ui-core.d.ts +878 -11
@@ -0,0 +1,46 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnyRadioDirective } from './radio.directive';
4
+ import type { RadioSize } from './radio.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyRadioDirective],
9
+ template: `<input type="radio" snyRadio [size]="size()" />`,
10
+ })
11
+ class TestHostComponent {
12
+ size = signal<RadioSize>('md');
13
+ }
14
+
15
+ describe('SnyRadioDirective', () => {
16
+ let fixture: ComponentFixture<TestHostComponent>;
17
+ let el: HTMLInputElement;
18
+
19
+ beforeEach(async () => {
20
+ await TestBed.configureTestingModule({
21
+ imports: [TestHostComponent],
22
+ }).compileComponents();
23
+
24
+ fixture = TestBed.createComponent(TestHostComponent);
25
+ fixture.detectChanges();
26
+ el = fixture.nativeElement.querySelector('input');
27
+ });
28
+
29
+ it('should apply default classes', () => {
30
+ expect(el.className).toContain('appearance-none');
31
+ expect(el.className).toContain('rounded-full');
32
+ expect(el.className).toContain('h-4');
33
+ });
34
+
35
+ it('should apply sm size', () => {
36
+ fixture.componentInstance.size.set('sm');
37
+ fixture.detectChanges();
38
+ expect(el.className).toContain('h-3.5');
39
+ });
40
+
41
+ it('should apply lg size', () => {
42
+ fixture.componentInstance.size.set('lg');
43
+ fixture.detectChanges();
44
+ expect(el.className).toContain('h-5');
45
+ });
46
+ });
@@ -0,0 +1,17 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { radioVariants, type RadioSize } from './radio.variants';
4
+
5
+ @Directive({
6
+ selector: 'input[type="radio"][snyRadio]',
7
+ standalone: true,
8
+ host: { '[class]': 'computedClass()' },
9
+ })
10
+ export class SnyRadioDirective {
11
+ readonly size = input<RadioSize>('md');
12
+ readonly class = input<string>('');
13
+
14
+ protected readonly computedClass = computed(() =>
15
+ cn(radioVariants({ size: this.size() }), this.class())
16
+ );
17
+ }
@@ -0,0 +1,19 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const radioVariants = cva(
4
+ 'appearance-none rounded-full border border-border bg-background transition-colors checked:border-primary checked:shadow-[inset_0_0_0_3px] checked:shadow-primary 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
+ size: {
8
+ sm: 'h-3.5 w-3.5',
9
+ md: 'h-4 w-4',
10
+ lg: 'h-5 w-5',
11
+ },
12
+ },
13
+ defaultVariants: {
14
+ size: 'md',
15
+ },
16
+ }
17
+ );
18
+
19
+ export type RadioSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,2 @@
1
+ export { SnyRatingComponent } from './rating.component';
2
+ export { ratingVariants, type RatingSize, type RatingVariant } from './rating.variants';
@@ -0,0 +1,157 @@
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 { SnyRatingComponent } from './rating.component';
5
+ import type { RatingSize } from './rating.variants';
6
+
7
+ @Component({
8
+ standalone: true,
9
+ imports: [SnyRatingComponent],
10
+ template: `
11
+ <sny-rating
12
+ [(value)]="value"
13
+ [max]="max()"
14
+ [readonly]="readonly()"
15
+ [size]="size()"
16
+ [half]="half()"
17
+ />
18
+ `,
19
+ })
20
+ class TestHostComponent {
21
+ value = signal(0);
22
+ max = signal(5);
23
+ readonly = signal(false);
24
+ size = signal<RatingSize>('md');
25
+ half = signal(false);
26
+ rating = viewChild(SnyRatingComponent);
27
+ }
28
+
29
+ describe('SnyRatingComponent', () => {
30
+ let fixture: ComponentFixture<TestHostComponent>;
31
+ let host: HTMLElement;
32
+
33
+ beforeEach(async () => {
34
+ await TestBed.configureTestingModule({
35
+ imports: [TestHostComponent],
36
+ }).compileComponents();
37
+ fixture = TestBed.createComponent(TestHostComponent);
38
+ fixture.detectChanges();
39
+ host = fixture.nativeElement.querySelector('sny-rating');
40
+ });
41
+
42
+ it('should render with slider role', () => {
43
+ expect(host.getAttribute('role')).toBe('slider');
44
+ });
45
+
46
+ it('should render correct number of stars', () => {
47
+ const stars = host.querySelectorAll('svg');
48
+ expect(stars.length).toBe(5);
49
+ });
50
+
51
+ it('should set aria-valuenow', () => {
52
+ expect(host.getAttribute('aria-valuenow')).toBe('0');
53
+ fixture.componentInstance.value.set(3);
54
+ fixture.detectChanges();
55
+ expect(host.getAttribute('aria-valuenow')).toBe('3');
56
+ });
57
+
58
+ it('should set value on star click', () => {
59
+ const stars = host.querySelectorAll('svg');
60
+ stars[2].dispatchEvent(new MouseEvent('click'));
61
+ fixture.detectChanges();
62
+ expect(fixture.componentInstance.value()).toBe(3);
63
+ });
64
+
65
+ it('should not change value when readonly', () => {
66
+ fixture.componentInstance.readonly.set(true);
67
+ fixture.detectChanges();
68
+ const stars = host.querySelectorAll('svg');
69
+ stars[2].dispatchEvent(new MouseEvent('click'));
70
+ fixture.detectChanges();
71
+ expect(fixture.componentInstance.value()).toBe(0);
72
+ });
73
+
74
+ it('should handle keyboard ArrowRight', () => {
75
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
76
+ fixture.detectChanges();
77
+ expect(fixture.componentInstance.value()).toBe(1);
78
+ });
79
+
80
+ it('should handle keyboard ArrowLeft', () => {
81
+ fixture.componentInstance.value.set(3);
82
+ fixture.detectChanges();
83
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
84
+ fixture.detectChanges();
85
+ expect(fixture.componentInstance.value()).toBe(2);
86
+ });
87
+
88
+ it('should handle Home and End keys', () => {
89
+ fixture.componentInstance.value.set(3);
90
+ fixture.detectChanges();
91
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
92
+ fixture.detectChanges();
93
+ expect(fixture.componentInstance.value()).toBe(0);
94
+
95
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
96
+ fixture.detectChanges();
97
+ expect(fixture.componentInstance.value()).toBe(5);
98
+ });
99
+
100
+ it('should render with custom max', () => {
101
+ fixture.componentInstance.max.set(10);
102
+ fixture.detectChanges();
103
+ const stars = host.querySelectorAll('svg');
104
+ expect(stars.length).toBe(10);
105
+ });
106
+ });
107
+
108
+ @Component({
109
+ standalone: true,
110
+ imports: [ReactiveFormsModule, SnyRatingComponent],
111
+ template: `<sny-rating [formControl]="ctrl" />`,
112
+ })
113
+ class ReactiveFormHost {
114
+ ctrl = new FormControl(0);
115
+ }
116
+
117
+ describe('SnyRatingComponent — Reactive Forms', () => {
118
+ let fixture: ComponentFixture<ReactiveFormHost>;
119
+ let host: HTMLElement;
120
+
121
+ beforeEach(async () => {
122
+ await TestBed.configureTestingModule({
123
+ imports: [ReactiveFormHost],
124
+ }).compileComponents();
125
+ fixture = TestBed.createComponent(ReactiveFormHost);
126
+ fixture.detectChanges();
127
+ host = fixture.nativeElement.querySelector('sny-rating');
128
+ });
129
+
130
+ it('should update view when FormControl value changes (writeValue)', () => {
131
+ fixture.componentInstance.ctrl.setValue(4);
132
+ fixture.detectChanges();
133
+ expect(host.getAttribute('aria-valuenow')).toBe('4');
134
+ });
135
+
136
+ it('should update FormControl when user interacts (onChange)', () => {
137
+ const stars = host.querySelectorAll('svg');
138
+ stars[2].dispatchEvent(new MouseEvent('click'));
139
+ fixture.detectChanges();
140
+ expect(fixture.componentInstance.ctrl.value).toBe(3);
141
+ });
142
+
143
+ it('should disable via FormControl.disable() (setDisabledState)', () => {
144
+ fixture.componentInstance.ctrl.disable();
145
+ fixture.detectChanges();
146
+ const stars = host.querySelectorAll('svg');
147
+ stars[2].dispatchEvent(new MouseEvent('click'));
148
+ fixture.detectChanges();
149
+ expect(fixture.componentInstance.ctrl.value).toBe(0);
150
+ });
151
+
152
+ it('should mark as touched on blur (onTouched)', () => {
153
+ expect(fixture.componentInstance.ctrl.touched).toBe(false);
154
+ host.dispatchEvent(new Event('blur'));
155
+ expect(fixture.componentInstance.ctrl.touched).toBe(true);
156
+ });
157
+ });
@@ -0,0 +1,171 @@
1
+ import { ChangeDetectionStrategy, Component, computed, effect, forwardRef, input, model, signal } from '@angular/core';
2
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
3
+ import { cn } from '../core/utils/cn';
4
+ import { ratingVariants, type RatingSize, type RatingVariant } from './rating.variants';
5
+
6
+ interface StarState {
7
+ index: number;
8
+ fill: 'full' | 'half' | 'empty';
9
+ }
10
+
11
+ @Component({
12
+ selector: 'sny-rating',
13
+ standalone: true,
14
+ changeDetection: ChangeDetectionStrategy.OnPush,
15
+ host: {
16
+ 'role': 'slider',
17
+ 'tabindex': '0',
18
+ '[attr.aria-valuenow]': 'value()',
19
+ '[attr.aria-valuemin]': '0',
20
+ '[attr.aria-valuemax]': 'max()',
21
+ '[attr.aria-label]': '"Rating"',
22
+ '[attr.aria-readonly]': 'readonly() || null',
23
+ '[class]': 'computedClass()',
24
+ '(keydown)': 'onKeydown($event)',
25
+ '(mouseleave)': 'onMouseLeave()',
26
+ '(blur)': 'onTouched()',
27
+ },
28
+ providers: [
29
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyRatingComponent), multi: true },
30
+ ],
31
+ template: `
32
+ @for (star of stars(); track star.index) {
33
+ <svg
34
+ xmlns="http://www.w3.org/2000/svg"
35
+ viewBox="0 0 24 24"
36
+ [class]="starClass(star)"
37
+ (click)="onStarClick(star.index + 1)"
38
+ (mouseenter)="onStarHover(star.index + 1)"
39
+ >
40
+ @if (ratingVariant() === 'star') {
41
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
42
+ } @else {
43
+ <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
44
+ }
45
+ </svg>
46
+ }
47
+ `,
48
+ })
49
+ export class SnyRatingComponent implements ControlValueAccessor {
50
+ readonly value = model(0);
51
+ readonly max = input(5);
52
+ readonly readonly = input(false);
53
+ readonly size = input<RatingSize>('md');
54
+ readonly ratingVariant = input<RatingVariant>('star');
55
+ readonly half = input(false);
56
+ readonly class = input<string>('');
57
+
58
+ private readonly _disabledByCva = signal(false);
59
+ protected readonly isDisabled = computed(() => this.readonly() || this._disabledByCva());
60
+
61
+ readonly hoverValue = signal<number | null>(null);
62
+
63
+ private _onChange: (value: number) => void = () => {};
64
+ protected onTouched: () => void = () => {};
65
+ private _writing = false;
66
+
67
+ constructor() {
68
+ effect(() => {
69
+ const val = this.value();
70
+ if (this._writing) {
71
+ this._writing = false;
72
+ return;
73
+ }
74
+ this._onChange(val);
75
+ });
76
+ }
77
+
78
+ writeValue(val: number): void {
79
+ this._writing = true;
80
+ this.value.set(val ?? 0);
81
+ }
82
+
83
+ registerOnChange(fn: (value: number) => void): void {
84
+ this._onChange = fn;
85
+ }
86
+
87
+ registerOnTouched(fn: () => void): void {
88
+ this.onTouched = fn;
89
+ }
90
+
91
+ setDisabledState(isDisabled: boolean): void {
92
+ this._disabledByCva.set(isDisabled);
93
+ }
94
+
95
+ readonly displayValue = computed(() => this.hoverValue() ?? this.value());
96
+
97
+ readonly stars = computed<StarState[]>(() => {
98
+ const display = this.displayValue();
99
+ const m = this.max();
100
+ return Array.from({ length: m }, (_, i) => {
101
+ let fill: 'full' | 'half' | 'empty';
102
+ if (i + 1 <= display) {
103
+ fill = 'full';
104
+ } else if (this.half() && i + 0.5 <= display) {
105
+ fill = 'half';
106
+ } else {
107
+ fill = 'empty';
108
+ }
109
+ return { index: i, fill };
110
+ });
111
+ });
112
+
113
+ protected readonly computedClass = computed(() =>
114
+ cn(
115
+ ratingVariants({ size: this.size() }),
116
+ this.isDisabled() && 'pointer-events-none',
117
+ this.class()
118
+ )
119
+ );
120
+
121
+ starClass(star: StarState): string {
122
+ const base = 'cursor-pointer transition-colors';
123
+ switch (star.fill) {
124
+ case 'full':
125
+ return cn(base, 'fill-yellow-400 stroke-yellow-400');
126
+ case 'half':
127
+ return cn(base, 'fill-yellow-400/50 stroke-yellow-400');
128
+ case 'empty':
129
+ return cn(base, 'fill-none stroke-muted-foreground');
130
+ }
131
+ }
132
+
133
+ onStarClick(index: number): void {
134
+ if (this.isDisabled()) return;
135
+ this.value.set(index);
136
+ }
137
+
138
+ onStarHover(index: number): void {
139
+ if (this.isDisabled()) return;
140
+ this.hoverValue.set(index);
141
+ }
142
+
143
+ onMouseLeave(): void {
144
+ this.hoverValue.set(null);
145
+ }
146
+
147
+ onKeydown(event: KeyboardEvent): void {
148
+ if (this.isDisabled()) return;
149
+ const step = this.half() ? 0.5 : 1;
150
+ switch (event.key) {
151
+ case 'ArrowRight':
152
+ case 'ArrowUp':
153
+ event.preventDefault();
154
+ this.value.update((v) => Math.min(this.max(), v + step));
155
+ break;
156
+ case 'ArrowLeft':
157
+ case 'ArrowDown':
158
+ event.preventDefault();
159
+ this.value.update((v) => Math.max(0, v - step));
160
+ break;
161
+ case 'Home':
162
+ event.preventDefault();
163
+ this.value.set(0);
164
+ break;
165
+ case 'End':
166
+ event.preventDefault();
167
+ this.value.set(this.max());
168
+ break;
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,20 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const ratingVariants = cva(
4
+ 'inline-flex items-center gap-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: '[&_svg]:w-4 [&_svg]:h-4',
9
+ md: '[&_svg]:w-5 [&_svg]:h-5',
10
+ lg: '[&_svg]:w-7 [&_svg]:h-7',
11
+ },
12
+ },
13
+ defaultVariants: {
14
+ size: 'md',
15
+ },
16
+ }
17
+ );
18
+
19
+ export type RatingSize = 'sm' | 'md' | 'lg';
20
+ export type RatingVariant = 'star' | 'heart';
@@ -0,0 +1,2 @@
1
+ export { SnySelectComponent, type SelectOption } from './select.component';
2
+ export { selectTriggerVariants, type SelectSize } from './select.variants';
@@ -0,0 +1,112 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
4
+ import { SnySelectComponent, type SelectOption } from './select.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnySelectComponent],
9
+ template: `<sny-select [options]="options" [(value)]="value" [placeholder]="placeholder()" />`,
10
+ })
11
+ class TestHostComponent {
12
+ options: SelectOption[] = [
13
+ { value: 'a', label: 'Option A' },
14
+ { value: 'b', label: 'Option B' },
15
+ { value: 'c', label: 'Option C' },
16
+ ];
17
+ value = signal('');
18
+ placeholder = signal('Pick one...');
19
+ }
20
+
21
+ describe('SnySelectComponent', () => {
22
+ let fixture: ComponentFixture<TestHostComponent>;
23
+ let trigger: HTMLButtonElement;
24
+
25
+ beforeEach(async () => {
26
+ await TestBed.configureTestingModule({
27
+ imports: [TestHostComponent],
28
+ }).compileComponents();
29
+
30
+ fixture = TestBed.createComponent(TestHostComponent);
31
+ fixture.detectChanges();
32
+ trigger = fixture.nativeElement.querySelector('button');
33
+ });
34
+
35
+ it('should show placeholder when no value', () => {
36
+ expect(trigger.textContent).toContain('Pick one...');
37
+ });
38
+
39
+ it('should have combobox role', () => {
40
+ expect(trigger.getAttribute('role')).toBe('combobox');
41
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
42
+ });
43
+
44
+ it('should open dropdown on click', () => {
45
+ trigger.click();
46
+ fixture.detectChanges();
47
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
48
+ const options = fixture.nativeElement.querySelectorAll('[role="option"]');
49
+ expect(options.length).toBe(3);
50
+ });
51
+
52
+ it('should show selected label', () => {
53
+ fixture.componentInstance.value.set('b');
54
+ fixture.detectChanges();
55
+ expect(trigger.textContent).toContain('Option B');
56
+ });
57
+ });
58
+
59
+ @Component({
60
+ standalone: true,
61
+ imports: [ReactiveFormsModule, SnySelectComponent],
62
+ template: `<sny-select [options]="options" [formControl]="ctrl" />`,
63
+ })
64
+ class ReactiveFormHost {
65
+ options: SelectOption[] = [
66
+ { value: 'a', label: 'Option A' },
67
+ { value: 'b', label: 'Option B' },
68
+ { value: 'c', label: 'Option C' },
69
+ ];
70
+ ctrl = new FormControl('');
71
+ }
72
+
73
+ describe('SnySelectComponent — Reactive Forms', () => {
74
+ let fixture: ComponentFixture<ReactiveFormHost>;
75
+ let trigger: HTMLButtonElement;
76
+
77
+ beforeEach(async () => {
78
+ await TestBed.configureTestingModule({
79
+ imports: [ReactiveFormHost],
80
+ }).compileComponents();
81
+ fixture = TestBed.createComponent(ReactiveFormHost);
82
+ fixture.detectChanges();
83
+ trigger = fixture.nativeElement.querySelector('button');
84
+ });
85
+
86
+ it('should update view when FormControl value changes (writeValue)', () => {
87
+ fixture.componentInstance.ctrl.setValue('b');
88
+ fixture.detectChanges();
89
+ expect(trigger.textContent).toContain('Option B');
90
+ });
91
+
92
+ it('should update FormControl when user interacts (onChange)', () => {
93
+ trigger.click();
94
+ fixture.detectChanges();
95
+ const option = fixture.nativeElement.querySelector('[role="option"]') as HTMLElement;
96
+ option.dispatchEvent(new Event('mousedown'));
97
+ fixture.detectChanges();
98
+ expect(fixture.componentInstance.ctrl.value).toBe('a');
99
+ });
100
+
101
+ it('should disable via FormControl.disable() (setDisabledState)', () => {
102
+ fixture.componentInstance.ctrl.disable();
103
+ fixture.detectChanges();
104
+ expect(trigger.disabled).toBe(true);
105
+ });
106
+
107
+ it('should mark as touched on blur (onTouched)', () => {
108
+ expect(fixture.componentInstance.ctrl.touched).toBe(false);
109
+ trigger.dispatchEvent(new Event('blur'));
110
+ expect(fixture.componentInstance.ctrl.touched).toBe(true);
111
+ });
112
+ });