@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,100 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
4
+ import { SnyToggleDirective } from './toggle.directive';
5
+ import type { ToggleVariant, ToggleSize } from './toggle.variants';
6
+
7
+ @Component({
8
+ standalone: true,
9
+ imports: [SnyToggleDirective],
10
+ template: `<button snyToggle [variant]="variant()" [size]="size()" [(pressed)]="pressed">Toggle</button>`,
11
+ })
12
+ class TestHostComponent {
13
+ variant = signal<ToggleVariant>('default');
14
+ size = signal<ToggleSize>('md');
15
+ pressed = signal(false);
16
+ }
17
+
18
+ describe('SnyToggleDirective', () => {
19
+ let fixture: ComponentFixture<TestHostComponent>;
20
+ let button: HTMLButtonElement;
21
+
22
+ beforeEach(async () => {
23
+ await TestBed.configureTestingModule({
24
+ imports: [TestHostComponent],
25
+ }).compileComponents();
26
+
27
+ fixture = TestBed.createComponent(TestHostComponent);
28
+ fixture.detectChanges();
29
+ button = fixture.nativeElement.querySelector('button');
30
+ });
31
+
32
+ it('should apply default classes', () => {
33
+ expect(button.className).toContain('inline-flex');
34
+ expect(button.className).toContain('rounded-sm');
35
+ });
36
+
37
+ it('should be unpressed by default', () => {
38
+ expect(button.getAttribute('aria-pressed')).toBe('false');
39
+ });
40
+
41
+ it('should toggle pressed on click', () => {
42
+ button.click();
43
+ fixture.detectChanges();
44
+ expect(button.getAttribute('aria-pressed')).toBe('true');
45
+ expect(button.className).toContain('bg-accent');
46
+ });
47
+
48
+ it('should apply outline variant', () => {
49
+ fixture.componentInstance.variant.set('outline');
50
+ fixture.detectChanges();
51
+ expect(button.className).toContain('border');
52
+ });
53
+ });
54
+
55
+ @Component({
56
+ standalone: true,
57
+ imports: [ReactiveFormsModule, SnyToggleDirective],
58
+ template: `<button snyToggle [formControl]="ctrl">Toggle</button>`,
59
+ })
60
+ class ReactiveFormHost {
61
+ ctrl = new FormControl(false);
62
+ }
63
+
64
+ describe('SnyToggleDirective — Reactive Forms', () => {
65
+ let fixture: ComponentFixture<ReactiveFormHost>;
66
+ let button: HTMLButtonElement;
67
+
68
+ beforeEach(async () => {
69
+ await TestBed.configureTestingModule({
70
+ imports: [ReactiveFormHost],
71
+ }).compileComponents();
72
+ fixture = TestBed.createComponent(ReactiveFormHost);
73
+ fixture.detectChanges();
74
+ button = fixture.nativeElement.querySelector('button');
75
+ });
76
+
77
+ it('should update view when FormControl value changes (writeValue)', () => {
78
+ fixture.componentInstance.ctrl.setValue(true);
79
+ fixture.detectChanges();
80
+ expect(button.getAttribute('aria-pressed')).toBe('true');
81
+ });
82
+
83
+ it('should update FormControl when user interacts (onChange)', () => {
84
+ button.click();
85
+ fixture.detectChanges();
86
+ expect(fixture.componentInstance.ctrl.value).toBe(true);
87
+ });
88
+
89
+ it('should disable via FormControl.disable() (setDisabledState)', () => {
90
+ fixture.componentInstance.ctrl.disable();
91
+ fixture.detectChanges();
92
+ expect(button.disabled).toBe(true);
93
+ });
94
+
95
+ it('should mark as touched on blur (onTouched)', () => {
96
+ expect(fixture.componentInstance.ctrl.touched).toBe(false);
97
+ button.dispatchEvent(new Event('blur'));
98
+ expect(fixture.componentInstance.ctrl.touched).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1,61 @@
1
+ import { Directive, computed, 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 { toggleVariants, type ToggleVariant, type ToggleSize } from './toggle.variants';
5
+
6
+ @Directive({
7
+ selector: 'button[snyToggle]',
8
+ providers: [
9
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyToggleDirective), multi: true },
10
+ ],
11
+ host: {
12
+ '[class]': 'computedClass()',
13
+ '[attr.aria-pressed]': 'pressed()',
14
+ '[attr.disabled]': 'isDisabled() || null',
15
+ '(click)': 'toggle()',
16
+ '(blur)': 'onTouched()',
17
+ },
18
+ })
19
+ export class SnyToggleDirective implements ControlValueAccessor {
20
+ readonly variant = input<ToggleVariant>('default');
21
+ readonly size = input<ToggleSize>('md');
22
+ readonly pressed = model(false);
23
+ readonly class = input<string>('');
24
+
25
+ private readonly _disabledByCva = signal(false);
26
+ protected readonly isDisabled = computed(() => this._disabledByCva());
27
+
28
+ private _onChange: (value: boolean) => void = () => {};
29
+ protected onTouched: () => void = () => {};
30
+
31
+ writeValue(val: boolean): void {
32
+ this.pressed.set(val ?? false);
33
+ }
34
+
35
+ registerOnChange(fn: (value: boolean) => void): void {
36
+ this._onChange = fn;
37
+ }
38
+
39
+ registerOnTouched(fn: () => void): void {
40
+ this.onTouched = fn;
41
+ }
42
+
43
+ setDisabledState(isDisabled: boolean): void {
44
+ this._disabledByCva.set(isDisabled);
45
+ }
46
+
47
+ protected toggle(): void {
48
+ if (this.isDisabled()) return;
49
+ const newVal = !this.pressed();
50
+ this.pressed.set(newVal);
51
+ this._onChange(newVal);
52
+ }
53
+
54
+ protected readonly computedClass = computed(() =>
55
+ cn(
56
+ toggleVariants({ variant: this.variant(), size: this.size() }),
57
+ this.pressed() ? 'bg-accent text-accent-foreground' : '',
58
+ this.class()
59
+ )
60
+ );
61
+ }
@@ -0,0 +1,25 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const toggleVariants = cva(
4
+ 'inline-flex items-center justify-center rounded-sm text-sm font-medium 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',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default: 'bg-transparent hover:bg-muted hover:text-muted-foreground',
9
+ outline: 'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
10
+ },
11
+ size: {
12
+ sm: 'h-9 px-2.5',
13
+ md: 'h-10 px-3',
14
+ lg: 'h-11 px-5',
15
+ },
16
+ },
17
+ defaultVariants: {
18
+ variant: 'default',
19
+ size: 'md',
20
+ },
21
+ }
22
+ );
23
+
24
+ export type ToggleVariant = 'default' | 'outline';
25
+ export type ToggleSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,2 @@
1
+ export { SnyTooltipDirective } from './tooltip.directive';
2
+ export { tooltipVariants, type TooltipPosition } from './tooltip.variants';
@@ -0,0 +1,113 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyTooltipDirective } from './tooltip.directive';
4
+ import type { TooltipPosition } from './tooltip.variants';
5
+ import { vi } from 'vitest';
6
+
7
+ @Component({
8
+ standalone: true,
9
+ imports: [SnyTooltipDirective],
10
+ template: `
11
+ <button
12
+ [snyTooltip]="text()"
13
+ [tooltipPosition]="position()"
14
+ [tooltipDelay]="delay()"
15
+ [tooltipDisabled]="disabled()"
16
+ >
17
+ Hover me
18
+ </button>
19
+ `,
20
+ })
21
+ class TestHostComponent {
22
+ text = signal('Tooltip text');
23
+ position = signal<TooltipPosition>('top');
24
+ delay = signal(0);
25
+ disabled = signal(false);
26
+ }
27
+
28
+ describe('SnyTooltipDirective', () => {
29
+ let fixture: ComponentFixture<TestHostComponent>;
30
+ let button: HTMLButtonElement;
31
+
32
+ beforeEach(async () => {
33
+ vi.useFakeTimers();
34
+ await TestBed.configureTestingModule({
35
+ imports: [TestHostComponent],
36
+ }).compileComponents();
37
+ fixture = TestBed.createComponent(TestHostComponent);
38
+ fixture.detectChanges();
39
+ button = fixture.nativeElement.querySelector('button');
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.useRealTimers();
44
+ document.querySelectorAll('[role="tooltip"]').forEach((el) => el.remove());
45
+ });
46
+
47
+ it('should not show tooltip initially', () => {
48
+ expect(document.querySelector('[role="tooltip"]')).toBeNull();
49
+ expect(button.getAttribute('aria-describedby')).toBeNull();
50
+ });
51
+
52
+ it('should show tooltip on mouseenter after delay', () => {
53
+ button.dispatchEvent(new MouseEvent('mouseenter'));
54
+ vi.advanceTimersByTime(0);
55
+ fixture.detectChanges();
56
+
57
+ const tooltip = document.querySelector('[role="tooltip"]');
58
+ expect(tooltip).not.toBeNull();
59
+ expect(tooltip!.textContent).toBe('Tooltip text');
60
+ });
61
+
62
+ it('should hide tooltip on mouseleave', () => {
63
+ button.dispatchEvent(new MouseEvent('mouseenter'));
64
+ vi.advanceTimersByTime(0);
65
+ fixture.detectChanges();
66
+
67
+ button.dispatchEvent(new MouseEvent('mouseleave'));
68
+ fixture.detectChanges();
69
+
70
+ expect(document.querySelector('[role="tooltip"]')).toBeNull();
71
+ });
72
+
73
+ it('should set aria-describedby when visible', () => {
74
+ button.dispatchEvent(new MouseEvent('mouseenter'));
75
+ vi.advanceTimersByTime(0);
76
+ fixture.detectChanges();
77
+
78
+ expect(button.getAttribute('aria-describedby')).toBeTruthy();
79
+ });
80
+
81
+ it('should not show when disabled', () => {
82
+ fixture.componentInstance.disabled.set(true);
83
+ fixture.detectChanges();
84
+
85
+ button.dispatchEvent(new MouseEvent('mouseenter'));
86
+ vi.advanceTimersByTime(0);
87
+ fixture.detectChanges();
88
+
89
+ expect(document.querySelector('[role="tooltip"]')).toBeNull();
90
+ });
91
+
92
+ it('should hide on Escape key', () => {
93
+ button.dispatchEvent(new MouseEvent('mouseenter'));
94
+ vi.advanceTimersByTime(0);
95
+ fixture.detectChanges();
96
+
97
+ button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
98
+ fixture.detectChanges();
99
+
100
+ expect(document.querySelector('[role="tooltip"]')).toBeNull();
101
+ });
102
+
103
+ it('should show on focus and hide on blur', () => {
104
+ button.dispatchEvent(new Event('focus'));
105
+ vi.advanceTimersByTime(0);
106
+ fixture.detectChanges();
107
+ expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
108
+
109
+ button.dispatchEvent(new Event('blur'));
110
+ fixture.detectChanges();
111
+ expect(document.querySelector('[role="tooltip"]')).toBeNull();
112
+ });
113
+ });
@@ -0,0 +1,130 @@
1
+ import {
2
+ Directive,
3
+ ElementRef,
4
+ OnDestroy,
5
+ Renderer2,
6
+ computed,
7
+ inject,
8
+ input,
9
+ signal,
10
+ afterNextRender,
11
+ } from '@angular/core';
12
+ import { cn } from '../core/utils/cn';
13
+ import { tooltipVariants, type TooltipPosition } from './tooltip.variants';
14
+
15
+ let tooltipIdCounter = 0;
16
+
17
+ @Directive({
18
+ selector: '[snyTooltip]',
19
+ exportAs: 'snyTooltip',
20
+ host: {
21
+ '(mouseenter)': 'show()',
22
+ '(mouseleave)': 'hide()',
23
+ '(focus)': 'show()',
24
+ '(blur)': 'hide()',
25
+ '(keydown.escape)': 'hide()',
26
+ '[attr.aria-describedby]': 'isOpen() ? tooltipId : null',
27
+ },
28
+ })
29
+ export class SnyTooltipDirective implements OnDestroy {
30
+ readonly snyTooltip = input.required<string>();
31
+ readonly tooltipPosition = input<TooltipPosition>('top');
32
+ readonly tooltipDelay = input(300);
33
+ readonly tooltipDisabled = input(false);
34
+ readonly class = input<string>('');
35
+
36
+ readonly isOpen = signal(false);
37
+ readonly tooltipId = `sny-tooltip-${++tooltipIdCounter}`;
38
+
39
+ private readonly el = inject(ElementRef<HTMLElement>);
40
+ private readonly renderer = inject(Renderer2);
41
+ private showTimeout: ReturnType<typeof setTimeout> | null = null;
42
+ private tooltipEl: HTMLElement | null = null;
43
+
44
+ constructor() {
45
+ afterNextRender(() => {
46
+ // cleanup handled in ngOnDestroy
47
+ });
48
+ }
49
+
50
+ show(): void {
51
+ if (this.tooltipDisabled()) return;
52
+ this.showTimeout = setTimeout(() => {
53
+ this.createTooltip();
54
+ this.isOpen.set(true);
55
+ }, this.tooltipDelay());
56
+ }
57
+
58
+ hide(): void {
59
+ if (this.showTimeout) {
60
+ clearTimeout(this.showTimeout);
61
+ this.showTimeout = null;
62
+ }
63
+ this.destroyTooltip();
64
+ this.isOpen.set(false);
65
+ }
66
+
67
+ ngOnDestroy(): void {
68
+ this.hide();
69
+ }
70
+
71
+ private createTooltip(): void {
72
+ if (this.tooltipEl) return;
73
+
74
+ const tooltip = this.renderer.createElement('div') as HTMLElement;
75
+ tooltip.id = this.tooltipId;
76
+ tooltip.setAttribute('role', 'tooltip');
77
+ tooltip.className = cn(
78
+ tooltipVariants({ position: this.tooltipPosition() }),
79
+ 'fixed pointer-events-none',
80
+ this.class()
81
+ );
82
+ tooltip.textContent = this.snyTooltip();
83
+
84
+ this.renderer.appendChild(document.body, tooltip);
85
+ this.tooltipEl = tooltip;
86
+
87
+ this.positionTooltip();
88
+ }
89
+
90
+ private positionTooltip(): void {
91
+ if (!this.tooltipEl) return;
92
+
93
+ const hostRect = this.el.nativeElement.getBoundingClientRect();
94
+ const tooltipRect = this.tooltipEl.getBoundingClientRect();
95
+ const position = this.tooltipPosition();
96
+ const gap = 8;
97
+
98
+ let top = 0;
99
+ let left = 0;
100
+
101
+ switch (position) {
102
+ case 'top':
103
+ top = hostRect.top - tooltipRect.height - gap;
104
+ left = hostRect.left + (hostRect.width - tooltipRect.width) / 2;
105
+ break;
106
+ case 'bottom':
107
+ top = hostRect.bottom + gap;
108
+ left = hostRect.left + (hostRect.width - tooltipRect.width) / 2;
109
+ break;
110
+ case 'left':
111
+ top = hostRect.top + (hostRect.height - tooltipRect.height) / 2;
112
+ left = hostRect.left - tooltipRect.width - gap;
113
+ break;
114
+ case 'right':
115
+ top = hostRect.top + (hostRect.height - tooltipRect.height) / 2;
116
+ left = hostRect.right + gap;
117
+ break;
118
+ }
119
+
120
+ this.tooltipEl.style.top = `${top}px`;
121
+ this.tooltipEl.style.left = `${left}px`;
122
+ }
123
+
124
+ private destroyTooltip(): void {
125
+ if (this.tooltipEl) {
126
+ this.tooltipEl.remove();
127
+ this.tooltipEl = null;
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,20 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const tooltipVariants = cva(
4
+ 'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95',
5
+ {
6
+ variants: {
7
+ position: {
8
+ top: 'slide-in-from-bottom-2',
9
+ bottom: 'slide-in-from-top-2',
10
+ left: 'slide-in-from-right-2',
11
+ right: 'slide-in-from-left-2',
12
+ },
13
+ },
14
+ defaultVariants: {
15
+ position: 'top',
16
+ },
17
+ }
18
+ );
19
+
20
+ export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
@@ -0,0 +1,5 @@
1
+ export {
2
+ SnyValidatorDirective,
3
+ SnyValidatorHintDirective,
4
+ type ValidatorHintVariant,
5
+ } from './validator.directives';
@@ -0,0 +1,47 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyValidatorDirective, SnyValidatorHintDirective } from './validator.directives';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyValidatorDirective, SnyValidatorHintDirective],
8
+ template: `
9
+ <div snyValidator>
10
+ <div snyValidatorHint when="required" variant="error">Required</div>
11
+ <div snyValidatorHint when="minlength" variant="error">Too short</div>
12
+ <div snyValidatorHint variant="success">Looks good!</div>
13
+ </div>
14
+ `,
15
+ })
16
+ class TestHostComponent {}
17
+
18
+ describe('SnyValidatorDirective', () => {
19
+ let fixture: ComponentFixture<TestHostComponent>;
20
+
21
+ beforeEach(async () => {
22
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
23
+ fixture = TestBed.createComponent(TestHostComponent);
24
+ fixture.detectChanges();
25
+ });
26
+
27
+ it('should render validator container', () => {
28
+ const el = fixture.nativeElement.querySelector('[snyValidator]');
29
+ expect(el).toBeTruthy();
30
+ });
31
+
32
+ it('should render hints with alert role', () => {
33
+ const hints = fixture.nativeElement.querySelectorAll('[snyValidatorHint]');
34
+ expect(hints.length).toBe(3);
35
+ hints.forEach((h: HTMLElement) => expect(h.getAttribute('role')).toBe('alert'));
36
+ });
37
+
38
+ it('should apply error variant to error hints', () => {
39
+ const hints = fixture.nativeElement.querySelectorAll('[snyValidatorHint]');
40
+ expect(hints[0].className).toContain('text-destructive');
41
+ });
42
+
43
+ it('should apply success variant', () => {
44
+ const hints = fixture.nativeElement.querySelectorAll('[snyValidatorHint]');
45
+ expect(hints[2].className).toContain('text-green-600');
46
+ });
47
+ });
@@ -0,0 +1,50 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import type { AbstractControl } from '@angular/forms';
4
+
5
+ export type ValidatorHintVariant = 'error' | 'success' | 'warning' | 'info';
6
+
7
+ @Directive({
8
+ selector: '[snyValidator]',
9
+ host: { '[class]': 'computedClass()' },
10
+ })
11
+ export class SnyValidatorDirective {
12
+ readonly control = input<AbstractControl | null>(null);
13
+ readonly state = input<'default' | 'error' | 'success' | 'warning'>('default');
14
+ readonly class = input<string>('');
15
+
16
+ readonly errors = computed(() => this.control()?.errors ?? null);
17
+ readonly showErrors = computed(() => {
18
+ const c = this.control();
19
+ return c ? c.touched && c.invalid : false;
20
+ });
21
+
22
+ protected readonly computedClass = computed(() =>
23
+ cn('flex flex-col gap-1 mt-1', this.class())
24
+ );
25
+ }
26
+
27
+ @Directive({
28
+ selector: '[snyValidatorHint]',
29
+ host: {
30
+ 'role': 'alert',
31
+ '[class]': 'computedClass()',
32
+ },
33
+ })
34
+ export class SnyValidatorHintDirective {
35
+ readonly when = input<string>('');
36
+ readonly type = input<ValidatorHintVariant>('error');
37
+ /** @deprecated Use `type` instead */
38
+ readonly variant = input<ValidatorHintVariant | undefined>(undefined);
39
+ readonly class = input<string>('');
40
+
41
+ protected readonly computedClass = computed(() => {
42
+ const v = this.variant() ?? this.type();
43
+ const variantClass =
44
+ v === 'success' ? 'text-green-600 dark:text-green-400' :
45
+ v === 'warning' ? 'text-amber-600 dark:text-amber-400' :
46
+ v === 'info' ? 'text-blue-600 dark:text-blue-400' :
47
+ 'text-destructive';
48
+ return cn('text-xs', variantClass, this.class());
49
+ });
50
+ }
@@ -17,6 +17,8 @@
17
17
  --color-destructive-foreground: var(--sny-destructive-foreground);
18
18
  --color-card: var(--sny-card);
19
19
  --color-card-foreground: var(--sny-card-foreground);
20
+ --color-popover: var(--sny-popover);
21
+ --color-popover-foreground: var(--sny-popover-foreground);
20
22
  --color-border: var(--sny-border);
21
23
  --color-input: var(--sny-input);
22
24
  --color-ring: var(--sny-ring);
@@ -38,6 +40,8 @@
38
40
  --sny-destructive-foreground: #fafafa;
39
41
  --sny-card: #ffffff;
40
42
  --sny-card-foreground: #0a0a0a;
43
+ --sny-popover: #ffffff;
44
+ --sny-popover-foreground: #0a0a0a;
41
45
  --sny-border: #e5e5e5;
42
46
  --sny-input: #e5e5e5;
43
47
  --sny-ring: #0a0a0a;
@@ -60,6 +64,8 @@
60
64
  --sny-destructive-foreground: #fafafa;
61
65
  --sny-card: #0a0a0a;
62
66
  --sny-card-foreground: #fafafa;
67
+ --sny-popover: #171717;
68
+ --sny-popover-foreground: #fafafa;
63
69
  --sny-border: #262626;
64
70
  --sny-input: #262626;
65
71
  --sny-ring: #d4d4d4;
@@ -80,12 +86,39 @@
80
86
  --sny-destructive-foreground: #ffffff;
81
87
  --sny-card: #ffffff;
82
88
  --sny-card-foreground: #0f172a;
89
+ --sny-popover: #ffffff;
90
+ --sny-popover-foreground: #0f172a;
83
91
  --sny-border: #cbd5e1;
84
92
  --sny-input: #cbd5e1;
85
93
  --sny-ring: #3b82f6;
86
94
  --sny-radius: 0.375rem;
87
95
  }
88
96
 
97
+ /* ─── Custom scrollbar ─── */
98
+
99
+ .sny-scrollbar {
100
+ scrollbar-width: thin;
101
+ scrollbar-color: var(--sny-muted-foreground) transparent;
102
+ }
103
+
104
+ .sny-scrollbar::-webkit-scrollbar {
105
+ width: 6px;
106
+ height: 6px;
107
+ }
108
+
109
+ .sny-scrollbar::-webkit-scrollbar-track {
110
+ background: transparent;
111
+ }
112
+
113
+ .sny-scrollbar::-webkit-scrollbar-thumb {
114
+ background-color: var(--sny-muted-foreground);
115
+ border-radius: 9999px;
116
+ }
117
+
118
+ .sny-scrollbar::-webkit-scrollbar-thumb:hover {
119
+ background-color: var(--sny-foreground);
120
+ }
121
+
89
122
  /* ─── Dialog / Sheet overlay ─── */
90
123
 
91
124
  .sny-dialog-backdrop {