@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,71 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+ import { SnyToastService } from './toast.service';
3
+
4
+ describe('SnyToastService', () => {
5
+ let service: SnyToastService;
6
+
7
+ beforeEach(() => {
8
+ TestBed.configureTestingModule({});
9
+ service = TestBed.inject(SnyToastService);
10
+ });
11
+
12
+ it('should create a toast', () => {
13
+ const id = service.show({ title: 'Test', duration: 0 });
14
+ expect(id).toBeTruthy();
15
+ expect(service.toasts().length).toBe(1);
16
+ expect(service.toasts()[0].title).toBe('Test');
17
+ });
18
+
19
+ it('should create toast with variant', () => {
20
+ service.show({ title: 'Error', variant: 'destructive', duration: 0 });
21
+ expect(service.toasts()[0].variant).toBe('destructive');
22
+ });
23
+
24
+ it('should dismiss a toast', () => {
25
+ const id = service.show({ title: 'Test', duration: 0 });
26
+ service.dismiss(id);
27
+ expect(service.toasts().length).toBe(0);
28
+ });
29
+
30
+ it('should dismiss all toasts', () => {
31
+ service.show({ title: 'Test 1', duration: 0 });
32
+ service.show({ title: 'Test 2', duration: 0 });
33
+ expect(service.toasts().length).toBe(2);
34
+ service.dismissAll();
35
+ expect(service.toasts().length).toBe(0);
36
+ });
37
+
38
+ it('should auto-dismiss after duration', async () => {
39
+ service.show({ title: 'Test', duration: 50 });
40
+ expect(service.toasts().length).toBe(1);
41
+ await new Promise(resolve => setTimeout(resolve, 100));
42
+ expect(service.toasts().length).toBe(0);
43
+ });
44
+
45
+ it('should provide success shortcut', () => {
46
+ service.success('Done', 'Task completed');
47
+ const toast = service.toasts()[0];
48
+ expect(toast.variant).toBe('success');
49
+ expect(toast.description).toBe('Task completed');
50
+ service.dismiss(toast.id);
51
+ });
52
+
53
+ it('should provide error shortcut', () => {
54
+ service.error('Failed');
55
+ expect(service.toasts()[0].variant).toBe('destructive');
56
+ service.dismissAll();
57
+ });
58
+
59
+ it('should provide warning shortcut', () => {
60
+ service.warning('Caution');
61
+ expect(service.toasts()[0].variant).toBe('warning');
62
+ service.dismissAll();
63
+ });
64
+
65
+ it('should track count', () => {
66
+ expect(service.count()).toBe(0);
67
+ service.show({ title: 'A', duration: 0 });
68
+ service.show({ title: 'B', duration: 0 });
69
+ expect(service.count()).toBe(2);
70
+ });
71
+ });
@@ -0,0 +1,60 @@
1
+ import { Injectable, signal, computed } from '@angular/core';
2
+ import type { ToastConfig, ToastData } from './toast.variants';
3
+
4
+ @Injectable({ providedIn: 'root' })
5
+ export class SnyToastService {
6
+ private readonly _toasts = signal<ToastData[]>([]);
7
+ private readonly _timers = new Map<string, ReturnType<typeof setTimeout>>();
8
+ private _idCounter = 0;
9
+
10
+ readonly toasts = this._toasts.asReadonly();
11
+ readonly count = computed(() => this._toasts().length);
12
+
13
+ show(config: ToastConfig): string {
14
+ const id = `sny-toast-${++this._idCounter}`;
15
+ const toast: ToastData = {
16
+ id,
17
+ variant: 'default',
18
+ duration: 5000,
19
+ ...config,
20
+ };
21
+
22
+ this._toasts.update(toasts => [...toasts, toast]);
23
+
24
+ if (toast.duration && toast.duration > 0) {
25
+ const timer = setTimeout(() => this.dismiss(id), toast.duration);
26
+ this._timers.set(id, timer);
27
+ }
28
+
29
+ return id;
30
+ }
31
+
32
+ dismiss(id: string): void {
33
+ const timer = this._timers.get(id);
34
+ if (timer) {
35
+ clearTimeout(timer);
36
+ this._timers.delete(id);
37
+ }
38
+ this._toasts.update(toasts => toasts.filter(t => t.id !== id));
39
+ }
40
+
41
+ dismissAll(): void {
42
+ for (const timer of this._timers.values()) {
43
+ clearTimeout(timer);
44
+ }
45
+ this._timers.clear();
46
+ this._toasts.set([]);
47
+ }
48
+
49
+ success(title: string, description?: string): string {
50
+ return this.show({ title, description, variant: 'success' });
51
+ }
52
+
53
+ error(title: string, description?: string): string {
54
+ return this.show({ title, description, variant: 'destructive' });
55
+ }
56
+
57
+ warning(title: string, description?: string): string {
58
+ return this.show({ title, description, variant: 'warning' });
59
+ }
60
+ }
@@ -0,0 +1,38 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const toastVariants = cva(
4
+ 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-sm border p-6 pr-8 shadow-lg transition-all',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default: 'bg-background border-border text-foreground',
9
+ destructive: 'bg-destructive border-destructive text-destructive-foreground',
10
+ success: 'bg-green-600 border-green-600 text-white',
11
+ warning: 'bg-yellow-500 border-yellow-500 text-white',
12
+ },
13
+ },
14
+ defaultVariants: {
15
+ variant: 'default',
16
+ },
17
+ }
18
+ );
19
+
20
+ export type ToastVariant = 'default' | 'destructive' | 'success' | 'warning';
21
+ export type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';
22
+
23
+ export interface ToastAction {
24
+ label: string;
25
+ onClick: () => void;
26
+ }
27
+
28
+ export interface ToastConfig {
29
+ title: string;
30
+ description?: string;
31
+ variant?: ToastVariant;
32
+ duration?: number;
33
+ action?: ToastAction;
34
+ }
35
+
36
+ export interface ToastData extends ToastConfig {
37
+ id: string;
38
+ }
@@ -0,0 +1,38 @@
1
+ import { Component } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyToasterComponent } from './toaster.component';
4
+ import { SnyToastService } from './toast.service';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyToasterComponent],
9
+ template: `<sny-toaster />`,
10
+ })
11
+ class TestHostComponent {}
12
+
13
+ describe('SnyToasterComponent', () => {
14
+ let fixture: ComponentFixture<TestHostComponent>;
15
+ let service: SnyToastService;
16
+
17
+ beforeEach(async () => {
18
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
19
+ fixture = TestBed.createComponent(TestHostComponent);
20
+ service = TestBed.inject(SnyToastService);
21
+ fixture.detectChanges();
22
+ });
23
+
24
+ it('should render region with aria-label', () => {
25
+ const region = fixture.nativeElement.querySelector('[role="region"]');
26
+ expect(region).toBeTruthy();
27
+ expect(region.getAttribute('aria-label')).toBe('Notifications');
28
+ });
29
+
30
+ it('should render toast items with aria-atomic="true"', () => {
31
+ service.show({ title: 'Test toast' });
32
+ fixture.detectChanges();
33
+ const alert = fixture.nativeElement.querySelector('[role="alert"]');
34
+ expect(alert).toBeTruthy();
35
+ expect(alert.getAttribute('aria-atomic')).toBe('true');
36
+ expect(alert.getAttribute('aria-live')).toBe('polite');
37
+ });
38
+ });
@@ -0,0 +1,82 @@
1
+ import { ChangeDetectionStrategy, Component, inject, input, computed } from '@angular/core';
2
+ import { SnyToastService } from './toast.service';
3
+ import { toastVariants, type ToastPosition, type ToastVariant } from './toast.variants';
4
+ import { cn } from '../core/utils/cn';
5
+
6
+ @Component({
7
+ selector: 'sny-toaster',
8
+ standalone: true,
9
+ changeDetection: ChangeDetectionStrategy.OnPush,
10
+ template: `
11
+ <div [class]="containerClass()" role="region" aria-label="Notifications" tabindex="-1">
12
+ @for (toast of visibleToasts(); track toast.id) {
13
+ <div
14
+ [class]="toastClasses[toast.variant ?? 'default']"
15
+ role="alert"
16
+ aria-live="polite"
17
+ aria-atomic="true"
18
+ >
19
+ <div class="grid gap-1">
20
+ <div class="text-sm font-semibold">{{ toast.title }}</div>
21
+ @if (toast.description) {
22
+ <div class="text-sm opacity-90">{{ toast.description }}</div>
23
+ }
24
+ </div>
25
+ <div class="flex items-center gap-2">
26
+ @if (toast.action) {
27
+ <button
28
+ class="inline-flex h-8 shrink-0 items-center justify-center rounded-sm border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary"
29
+ (click)="toast.action!.onClick()"
30
+ >
31
+ {{ toast.action!.label }}
32
+ </button>
33
+ }
34
+ <button
35
+ class="absolute right-2 top-2 rounded-sm p-1 opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100 focus:opacity-100"
36
+ aria-label="Close"
37
+ (click)="dismiss(toast.id)"
38
+ >
39
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
40
+ </button>
41
+ </div>
42
+ </div>
43
+ }
44
+ </div>
45
+ `,
46
+ })
47
+ export class SnyToasterComponent {
48
+ private readonly toastService = inject(SnyToastService);
49
+
50
+ readonly position = input<ToastPosition>('bottom-right');
51
+ readonly maxToasts = input(5);
52
+
53
+ readonly visibleToasts = computed(() =>
54
+ this.toastService.toasts().slice(-this.maxToasts())
55
+ );
56
+
57
+ readonly containerClass = computed(() => {
58
+ const pos = this.position();
59
+ const base = 'fixed z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:max-w-[420px]';
60
+ const posMap: Record<ToastPosition, string> = {
61
+ 'top-right': 'top-0 right-0',
62
+ 'top-left': 'top-0 left-0',
63
+ 'bottom-right': 'bottom-0 right-0',
64
+ 'bottom-left': 'bottom-0 left-0',
65
+ 'top-center': 'top-0 left-1/2 -translate-x-1/2',
66
+ 'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2',
67
+ };
68
+ return cn(base, posMap[pos]);
69
+ });
70
+
71
+ /** Pre-computed toast classes by variant — avoids method calls in the template. */
72
+ readonly toastClasses: Record<ToastVariant, string> = {
73
+ default: cn(toastVariants({ variant: 'default' })),
74
+ destructive: cn(toastVariants({ variant: 'destructive' })),
75
+ success: cn(toastVariants({ variant: 'success' })),
76
+ warning: cn(toastVariants({ variant: 'warning' })),
77
+ };
78
+
79
+ dismiss(id: string): void {
80
+ this.toastService.dismiss(id);
81
+ }
82
+ }
@@ -0,0 +1,2 @@
1
+ export { SnyToggleDirective } from './toggle.directive';
2
+ export { toggleVariants, type ToggleVariant, type ToggleSize } from './toggle.variants';
@@ -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,73 @@
1
+ import { Directive, 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 { toggleVariants, type ToggleVariant, type ToggleSize } from './toggle.variants';
5
+
6
+ @Directive({
7
+ selector: 'button[snyToggle]',
8
+ standalone: true,
9
+ providers: [
10
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyToggleDirective), multi: true },
11
+ ],
12
+ host: {
13
+ '[class]': 'computedClass()',
14
+ '[attr.aria-pressed]': 'pressed()',
15
+ '[attr.disabled]': 'isDisabled() || null',
16
+ '(click)': 'toggle()',
17
+ '(blur)': 'onTouched()',
18
+ },
19
+ })
20
+ export class SnyToggleDirective implements ControlValueAccessor {
21
+ readonly variant = input<ToggleVariant>('default');
22
+ readonly size = input<ToggleSize>('md');
23
+ readonly pressed = model(false);
24
+ readonly class = input<string>('');
25
+
26
+ private readonly _disabledByCva = signal(false);
27
+ protected readonly isDisabled = computed(() => this._disabledByCva());
28
+
29
+ private _onChange: (value: boolean) => void = () => {};
30
+ protected onTouched: () => void = () => {};
31
+ private _writing = false;
32
+
33
+ constructor() {
34
+ effect(() => {
35
+ const val = this.pressed();
36
+ if (this._writing) {
37
+ this._writing = false;
38
+ return;
39
+ }
40
+ this._onChange(val);
41
+ });
42
+ }
43
+
44
+ writeValue(val: boolean): void {
45
+ this._writing = true;
46
+ this.pressed.set(val ?? false);
47
+ }
48
+
49
+ registerOnChange(fn: (value: boolean) => void): void {
50
+ this._onChange = fn;
51
+ }
52
+
53
+ registerOnTouched(fn: () => void): void {
54
+ this.onTouched = fn;
55
+ }
56
+
57
+ setDisabledState(isDisabled: boolean): void {
58
+ this._disabledByCva.set(isDisabled);
59
+ }
60
+
61
+ protected toggle(): void {
62
+ if (this.isDisabled()) return;
63
+ this.pressed.set(!this.pressed());
64
+ }
65
+
66
+ protected readonly computedClass = computed(() =>
67
+ cn(
68
+ toggleVariants({ variant: this.variant(), size: this.size() }),
69
+ this.pressed() ? 'bg-accent text-accent-foreground' : '',
70
+ this.class()
71
+ )
72
+ );
73
+ }
@@ -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
+ });