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

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 +1 -1
  7. package/schematics/ng-generate/component/schema.json +1 -1
  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,18 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const buttonGroupVariants = cva(
4
+ 'inline-flex [&>*:not(:first-child):not(:last-child)]:rounded-none [&>*:first-child:not(:last-child)]:rounded-r-none [&>*:last-child:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:-ml-px',
5
+ {
6
+ variants: {
7
+ orientation: {
8
+ horizontal: 'flex-row',
9
+ vertical: 'flex-col [&>*:not(:first-child):not(:last-child)]:rounded-none [&>*:first-child:not(:last-child)]:rounded-b-none [&>*:first-child:not(:last-child)]:rounded-r-sm [&>*:last-child:not(:first-child)]:rounded-t-none [&>*:last-child:not(:first-child)]:rounded-l-sm [&>*:not(:first-child)]:-mt-px [&>*:not(:first-child)]:ml-0',
10
+ },
11
+ },
12
+ defaultVariants: {
13
+ orientation: 'horizontal',
14
+ },
15
+ }
16
+ );
17
+
18
+ export type ButtonGroupOrientation = 'horizontal' | 'vertical';
@@ -0,0 +1,2 @@
1
+ export { SnyButtonGroupDirective } from './button-group.directive';
2
+ export { buttonGroupVariants, type ButtonGroupOrientation } from './button-group.variants';
@@ -0,0 +1,105 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
4
+ import { SnyCalendarComponent } from './calendar.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyCalendarComponent],
9
+ template: `<sny-calendar [(value)]="selectedDate" />`,
10
+ })
11
+ class TestHostComponent {
12
+ selectedDate = signal<Date | null>(null);
13
+ }
14
+
15
+ describe('SnyCalendarComponent', () => {
16
+ let fixture: ComponentFixture<TestHostComponent>;
17
+ let host: HTMLElement;
18
+
19
+ beforeEach(async () => {
20
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
21
+ fixture = TestBed.createComponent(TestHostComponent);
22
+ fixture.detectChanges();
23
+ host = fixture.nativeElement.querySelector('sny-calendar');
24
+ });
25
+
26
+ it('should render a grid', () => {
27
+ const grid = host.querySelector('[role="grid"]');
28
+ expect(grid).not.toBeNull();
29
+ });
30
+
31
+ it('should render day headers', () => {
32
+ const headers = host.querySelectorAll('[role="grid"] > div:not(button)');
33
+ expect(headers.length).toBe(7);
34
+ });
35
+
36
+ it('should render 42 day buttons', () => {
37
+ const buttons = host.querySelectorAll('[role="grid"] button');
38
+ expect(buttons.length).toBe(42);
39
+ });
40
+
41
+ it('should navigate months', () => {
42
+ const prevBtn = host.querySelector('[aria-label="Previous month"]') as HTMLButtonElement;
43
+ const nextBtn = host.querySelector('[aria-label="Next month"]') as HTMLButtonElement;
44
+ expect(prevBtn).not.toBeNull();
45
+ expect(nextBtn).not.toBeNull();
46
+ });
47
+
48
+ it('should select a date on click', () => {
49
+ const buttons = host.querySelectorAll('[role="grid"] button:not([disabled])');
50
+ const dayButton = Array.from(buttons).find((b) => b.textContent?.trim() === '15') as HTMLButtonElement;
51
+ if (dayButton) {
52
+ dayButton.click();
53
+ fixture.detectChanges();
54
+ expect(fixture.componentInstance.selectedDate()).not.toBeNull();
55
+ }
56
+ });
57
+ });
58
+
59
+ @Component({
60
+ standalone: true,
61
+ imports: [ReactiveFormsModule, SnyCalendarComponent],
62
+ template: `<sny-calendar [formControl]="ctrl" />`,
63
+ })
64
+ class ReactiveFormHost {
65
+ ctrl = new FormControl<Date | null>(null);
66
+ }
67
+
68
+ describe('SnyCalendarComponent — Reactive Forms', () => {
69
+ let fixture: ComponentFixture<ReactiveFormHost>;
70
+ let host: HTMLElement;
71
+
72
+ beforeEach(async () => {
73
+ await TestBed.configureTestingModule({ imports: [ReactiveFormHost] }).compileComponents();
74
+ fixture = TestBed.createComponent(ReactiveFormHost);
75
+ fixture.detectChanges();
76
+ host = fixture.nativeElement.querySelector('sny-calendar');
77
+ });
78
+
79
+ it('should update view when FormControl value changes (writeValue)', () => {
80
+ const date = new Date(2025, 5, 15);
81
+ fixture.componentInstance.ctrl.setValue(date);
82
+ fixture.detectChanges();
83
+ const selected = host.querySelector('[role="grid"] button[aria-selected="true"]');
84
+ expect(selected).not.toBeNull();
85
+ });
86
+
87
+ it('should update FormControl when user interacts (onChange)', () => {
88
+ const buttons = host.querySelectorAll('[role="grid"] button:not([disabled])');
89
+ const dayButton = Array.from(buttons).find((b) => b.textContent?.trim() === '15') as HTMLButtonElement;
90
+ if (dayButton) {
91
+ dayButton.click();
92
+ fixture.detectChanges();
93
+ expect(fixture.componentInstance.ctrl.value).not.toBeNull();
94
+ expect(fixture.componentInstance.ctrl.value!.getDate()).toBe(15);
95
+ }
96
+ });
97
+
98
+ it('should disable via FormControl.disable() (setDisabledState)', () => {
99
+ fixture.componentInstance.ctrl.disable();
100
+ fixture.detectChanges();
101
+ const buttons = host.querySelectorAll('[role="grid"] button');
102
+ const allDisabled = Array.from(buttons).every((b) => (b as HTMLButtonElement).disabled || b.getAttribute('aria-disabled') === 'true');
103
+ expect(allDisabled).toBe(true);
104
+ });
105
+ });
@@ -0,0 +1,231 @@
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
+
5
+ interface CalendarDay {
6
+ date: Date;
7
+ day: number;
8
+ isCurrentMonth: boolean;
9
+ isToday: boolean;
10
+ isSelected: boolean;
11
+ isDisabled: boolean;
12
+ }
13
+
14
+ @Component({
15
+ selector: 'sny-calendar',
16
+ standalone: true,
17
+ changeDetection: ChangeDetectionStrategy.OnPush,
18
+ host: {
19
+ '[class]': '"inline-block p-3 rounded-md border bg-background"',
20
+ '(keydown)': 'onKeydown($event)',
21
+ },
22
+ providers: [
23
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyCalendarComponent), multi: true },
24
+ ],
25
+ template: `
26
+ <div class="flex items-center justify-between mb-4">
27
+ <button
28
+ class="inline-flex items-center justify-center rounded-md text-sm h-7 w-7 hover:bg-accent"
29
+ (click)="prevMonth()"
30
+ aria-label="Previous month"
31
+ >
32
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
33
+ </button>
34
+ <span class="text-sm font-medium">{{ monthYearLabel() }}</span>
35
+ <button
36
+ class="inline-flex items-center justify-center rounded-md text-sm h-7 w-7 hover:bg-accent"
37
+ (click)="nextMonth()"
38
+ aria-label="Next month"
39
+ >
40
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
41
+ </button>
42
+ </div>
43
+
44
+ <div role="grid" aria-label="Calendar" class="grid grid-cols-7 gap-0">
45
+ @for (dayName of weekDays; track dayName) {
46
+ <div class="text-center text-xs text-muted-foreground font-medium py-1">{{ dayName }}</div>
47
+ }
48
+ @for (day of days(); track day.date.getTime()) {
49
+ <button
50
+ [class]="dayClass(day)"
51
+ [disabled]="day.isDisabled"
52
+ [attr.aria-selected]="day.isSelected || null"
53
+ [attr.aria-current]="day.isToday ? 'date' : null"
54
+ [attr.aria-disabled]="day.isDisabled || null"
55
+ (click)="selectDate(day.date)"
56
+ >
57
+ {{ day.day }}
58
+ </button>
59
+ }
60
+ </div>
61
+ `,
62
+ })
63
+ export class SnyCalendarComponent implements ControlValueAccessor {
64
+ readonly value = model<Date | null>(null);
65
+ readonly min = input<Date | undefined>(undefined);
66
+ readonly max = input<Date | undefined>(undefined);
67
+ readonly locale = input('en-US');
68
+ readonly class = input<string>('');
69
+
70
+ private readonly _disabledByCva = signal(false);
71
+
72
+ readonly viewDate = signal(new Date());
73
+ readonly weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
74
+
75
+ private _onChange: (value: Date | null) => void = () => {};
76
+ protected onTouched: () => void = () => {};
77
+ private _writing = false;
78
+
79
+ constructor() {
80
+ effect(() => {
81
+ const val = this.value();
82
+ if (this._writing) {
83
+ this._writing = false;
84
+ return;
85
+ }
86
+ this._onChange(val);
87
+ });
88
+ }
89
+
90
+ writeValue(val: Date | null): void {
91
+ this._writing = true;
92
+ this.value.set(val ?? null);
93
+ if (val) {
94
+ this.viewDate.set(new Date(val.getFullYear(), val.getMonth(), 1));
95
+ }
96
+ }
97
+
98
+ registerOnChange(fn: (value: Date | null) => void): void {
99
+ this._onChange = fn;
100
+ }
101
+
102
+ registerOnTouched(fn: () => void): void {
103
+ this.onTouched = fn;
104
+ }
105
+
106
+ setDisabledState(isDisabled: boolean): void {
107
+ this._disabledByCva.set(isDisabled);
108
+ }
109
+
110
+ readonly monthYearLabel = computed(() => {
111
+ const d = this.viewDate();
112
+ return d.toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' });
113
+ });
114
+
115
+ readonly days = computed<CalendarDay[]>(() => {
116
+ const view = this.viewDate();
117
+ const year = view.getFullYear();
118
+ const month = view.getMonth();
119
+ const today = new Date();
120
+ const selected = this.value();
121
+ const minDate = this.min();
122
+ const maxDate = this.max();
123
+
124
+ const firstDay = new Date(year, month, 1);
125
+ const startDay = firstDay.getDay();
126
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
127
+ const daysInPrevMonth = new Date(year, month, 0).getDate();
128
+
129
+ const days: CalendarDay[] = [];
130
+
131
+ // Previous month
132
+ for (let i = startDay - 1; i >= 0; i--) {
133
+ const date = new Date(year, month - 1, daysInPrevMonth - i);
134
+ days.push(this.createDay(date, false, today, selected, minDate, maxDate));
135
+ }
136
+
137
+ // Current month
138
+ for (let d = 1; d <= daysInMonth; d++) {
139
+ const date = new Date(year, month, d);
140
+ days.push(this.createDay(date, true, today, selected, minDate, maxDate));
141
+ }
142
+
143
+ // Next month fill
144
+ const remaining = 42 - days.length;
145
+ for (let d = 1; d <= remaining; d++) {
146
+ const date = new Date(year, month + 1, d);
147
+ days.push(this.createDay(date, false, today, selected, minDate, maxDate));
148
+ }
149
+
150
+ return days;
151
+ });
152
+
153
+ prevMonth(): void {
154
+ this.viewDate.update((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1));
155
+ }
156
+
157
+ nextMonth(): void {
158
+ this.viewDate.update((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1));
159
+ }
160
+
161
+ selectDate(date: Date): void {
162
+ this.value.set(date);
163
+ this.onTouched();
164
+ }
165
+
166
+ onKeydown(event: KeyboardEvent): void {
167
+ // Simplified keyboard navigation
168
+ switch (event.key) {
169
+ case 'ArrowLeft':
170
+ event.preventDefault();
171
+ this.navigateDays(-1);
172
+ break;
173
+ case 'ArrowRight':
174
+ event.preventDefault();
175
+ this.navigateDays(1);
176
+ break;
177
+ case 'ArrowUp':
178
+ event.preventDefault();
179
+ this.navigateDays(-7);
180
+ break;
181
+ case 'ArrowDown':
182
+ event.preventDefault();
183
+ this.navigateDays(7);
184
+ break;
185
+ }
186
+ }
187
+
188
+ dayClass(day: CalendarDay): string {
189
+ return cn(
190
+ 'inline-flex items-center justify-center rounded-md text-sm h-8 w-8 transition-colors',
191
+ day.isCurrentMonth ? 'text-foreground' : 'text-muted-foreground/50',
192
+ day.isToday && !day.isSelected && 'bg-accent font-bold',
193
+ day.isSelected && 'bg-primary text-primary-foreground',
194
+ day.isDisabled && 'opacity-50 cursor-not-allowed',
195
+ !day.isDisabled && !day.isSelected && 'hover:bg-accent cursor-pointer'
196
+ );
197
+ }
198
+
199
+ private navigateDays(offset: number): void {
200
+ const current = this.value() ?? new Date();
201
+ const next = new Date(current);
202
+ next.setDate(next.getDate() + offset);
203
+ this.value.set(next);
204
+ this.viewDate.set(new Date(next.getFullYear(), next.getMonth(), 1));
205
+ }
206
+
207
+ private createDay(
208
+ date: Date,
209
+ isCurrentMonth: boolean,
210
+ today: Date,
211
+ selected: Date | null,
212
+ minDate: Date | undefined,
213
+ maxDate: Date | undefined
214
+ ): CalendarDay {
215
+ const isToday = this.isSameDay(date, today);
216
+ const isSelected = selected ? this.isSameDay(date, selected) : false;
217
+ const isDisabled =
218
+ this._disabledByCva() ||
219
+ (minDate ? date < minDate : false) || (maxDate ? date > maxDate : false);
220
+
221
+ return { date, day: date.getDate(), isCurrentMonth, isToday, isSelected, isDisabled };
222
+ }
223
+
224
+ private isSameDay(a: Date, b: Date): boolean {
225
+ return (
226
+ a.getFullYear() === b.getFullYear() &&
227
+ a.getMonth() === b.getMonth() &&
228
+ a.getDate() === b.getDate()
229
+ );
230
+ }
231
+ }
@@ -0,0 +1 @@
1
+ export { SnyCalendarComponent } from './calendar.component';
@@ -0,0 +1,104 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import {
4
+ SnyCardDirective,
5
+ SnyCardHeaderDirective,
6
+ SnyCardTitleDirective,
7
+ SnyCardDescriptionDirective,
8
+ SnyCardContentDirective,
9
+ SnyCardFooterDirective,
10
+ } from './card.directives';
11
+ import type { CardVariant, CardPadding } from './card.variants';
12
+
13
+ @Component({
14
+ standalone: true,
15
+ imports: [
16
+ SnyCardDirective,
17
+ SnyCardHeaderDirective,
18
+ SnyCardTitleDirective,
19
+ SnyCardDescriptionDirective,
20
+ SnyCardContentDirective,
21
+ SnyCardFooterDirective,
22
+ ],
23
+ template: `
24
+ <div snyCard [variant]="variant()" [padding]="padding()">
25
+ <div snyCardHeader>
26
+ <h3 snyCardTitle>Title</h3>
27
+ <p snyCardDescription>Description</p>
28
+ </div>
29
+ <div snyCardContent>Content</div>
30
+ <div snyCardFooter>Footer</div>
31
+ </div>
32
+ `,
33
+ })
34
+ class TestHostComponent {
35
+ variant = signal<CardVariant>('default');
36
+ padding = signal<CardPadding>('none');
37
+ }
38
+
39
+ describe('Card Directives', () => {
40
+ let fixture: ComponentFixture<TestHostComponent>;
41
+
42
+ beforeEach(async () => {
43
+ await TestBed.configureTestingModule({
44
+ imports: [TestHostComponent],
45
+ }).compileComponents();
46
+ fixture = TestBed.createComponent(TestHostComponent);
47
+ fixture.detectChanges();
48
+ });
49
+
50
+ it('should apply default card classes', () => {
51
+ const card = fixture.nativeElement.querySelector('[snyCard]');
52
+ expect(card.className).toContain('bg-card');
53
+ expect(card.className).toContain('border');
54
+ });
55
+
56
+ it('should apply elevated variant', () => {
57
+ fixture.componentInstance.variant.set('elevated');
58
+ fixture.detectChanges();
59
+ const card = fixture.nativeElement.querySelector('[snyCard]');
60
+ expect(card.className).toContain('shadow-lg');
61
+ });
62
+
63
+ it('should apply ghost variant', () => {
64
+ fixture.componentInstance.variant.set('ghost');
65
+ fixture.detectChanges();
66
+ const card = fixture.nativeElement.querySelector('[snyCard]');
67
+ expect(card.className).toContain('bg-transparent');
68
+ });
69
+
70
+ it('should apply padding', () => {
71
+ fixture.componentInstance.padding.set('md');
72
+ fixture.detectChanges();
73
+ const card = fixture.nativeElement.querySelector('[snyCard]');
74
+ expect(card.className).toContain('p-6');
75
+ });
76
+
77
+ it('should render card header', () => {
78
+ const header = fixture.nativeElement.querySelector('[snyCardHeader]');
79
+ expect(header.className).toContain('flex');
80
+ expect(header.className).toContain('p-6');
81
+ });
82
+
83
+ it('should render card title', () => {
84
+ const title = fixture.nativeElement.querySelector('[snyCardTitle]');
85
+ expect(title.className).toContain('text-2xl');
86
+ expect(title.className).toContain('font-semibold');
87
+ });
88
+
89
+ it('should render card description', () => {
90
+ const desc = fixture.nativeElement.querySelector('[snyCardDescription]');
91
+ expect(desc.className).toContain('text-muted-foreground');
92
+ });
93
+
94
+ it('should render content projection', () => {
95
+ const content = fixture.nativeElement.querySelector('[snyCardContent]');
96
+ expect(content.textContent).toContain('Content');
97
+ });
98
+
99
+ it('should render card footer', () => {
100
+ const footer = fixture.nativeElement.querySelector('[snyCardFooter]');
101
+ expect(footer.className).toContain('flex');
102
+ expect(footer.className).toContain('items-center');
103
+ });
104
+ });
@@ -0,0 +1,78 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { cardVariants, type CardVariant, type CardPadding } from './card.variants';
4
+
5
+ @Directive({
6
+ selector: '[snyCard]',
7
+ standalone: true,
8
+ host: { '[class]': 'computedClass()' },
9
+ })
10
+ export class SnyCardDirective {
11
+ readonly variant = input<CardVariant>('default');
12
+ readonly padding = input<CardPadding>('none');
13
+ readonly class = input<string>('');
14
+
15
+ protected readonly computedClass = computed(() =>
16
+ cn(cardVariants({ variant: this.variant(), padding: this.padding() }), this.class())
17
+ );
18
+ }
19
+
20
+ @Directive({
21
+ selector: '[snyCardHeader]',
22
+ standalone: true,
23
+ host: { '[class]': 'computedClass()' },
24
+ })
25
+ export class SnyCardHeaderDirective {
26
+ readonly class = input<string>('');
27
+ protected readonly computedClass = computed(() =>
28
+ cn('flex flex-col space-y-1.5 p-6', this.class())
29
+ );
30
+ }
31
+
32
+ @Directive({
33
+ selector: '[snyCardTitle]',
34
+ standalone: true,
35
+ host: { '[class]': 'computedClass()' },
36
+ })
37
+ export class SnyCardTitleDirective {
38
+ readonly class = input<string>('');
39
+ protected readonly computedClass = computed(() =>
40
+ cn('text-2xl font-semibold leading-none tracking-tight', this.class())
41
+ );
42
+ }
43
+
44
+ @Directive({
45
+ selector: '[snyCardDescription]',
46
+ standalone: true,
47
+ host: { '[class]': 'computedClass()' },
48
+ })
49
+ export class SnyCardDescriptionDirective {
50
+ readonly class = input<string>('');
51
+ protected readonly computedClass = computed(() =>
52
+ cn('text-sm text-muted-foreground', this.class())
53
+ );
54
+ }
55
+
56
+ @Directive({
57
+ selector: '[snyCardContent]',
58
+ standalone: true,
59
+ host: { '[class]': 'computedClass()' },
60
+ })
61
+ export class SnyCardContentDirective {
62
+ readonly class = input<string>('');
63
+ protected readonly computedClass = computed(() =>
64
+ cn('p-6 pt-0', this.class())
65
+ );
66
+ }
67
+
68
+ @Directive({
69
+ selector: '[snyCardFooter]',
70
+ standalone: true,
71
+ host: { '[class]': 'computedClass()' },
72
+ })
73
+ export class SnyCardFooterDirective {
74
+ readonly class = input<string>('');
75
+ protected readonly computedClass = computed(() =>
76
+ cn('flex items-center p-6 pt-0', this.class())
77
+ );
78
+ }
@@ -0,0 +1,28 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const cardVariants = cva(
4
+ 'rounded-sm text-card-foreground',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default: 'bg-card border border-border',
9
+ outline: 'border-2 border-border bg-transparent',
10
+ elevated: 'bg-card shadow-lg',
11
+ ghost: 'bg-transparent',
12
+ },
13
+ padding: {
14
+ none: '',
15
+ sm: 'p-4',
16
+ md: 'p-6',
17
+ lg: 'p-8',
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: 'default',
22
+ padding: 'none',
23
+ },
24
+ }
25
+ );
26
+
27
+ export type CardVariant = 'default' | 'outline' | 'elevated' | 'ghost';
28
+ export type CardPadding = 'none' | 'sm' | 'md' | 'lg';
@@ -0,0 +1,9 @@
1
+ export {
2
+ SnyCardDirective,
3
+ SnyCardHeaderDirective,
4
+ SnyCardTitleDirective,
5
+ SnyCardDescriptionDirective,
6
+ SnyCardContentDirective,
7
+ SnyCardFooterDirective,
8
+ } from './card.directives';
9
+ export { cardVariants, type CardVariant, type CardPadding } from './card.variants';
@@ -0,0 +1,85 @@
1
+ import { Component, viewChild } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyCarouselDirective, SnyCarouselContentDirective, SnyCarouselItemDirective } from './carousel.directives';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyCarouselDirective, SnyCarouselContentDirective, SnyCarouselItemDirective],
8
+ template: `
9
+ <div snyCarousel [loop]="true">
10
+ <div snyCarouselContent>
11
+ <div snyCarouselItem>Slide 1</div>
12
+ <div snyCarouselItem>Slide 2</div>
13
+ <div snyCarouselItem>Slide 3</div>
14
+ </div>
15
+ </div>
16
+ `,
17
+ })
18
+ class TestHostComponent {
19
+ carousel = viewChild(SnyCarouselDirective);
20
+ }
21
+
22
+ describe('SnyCarouselDirective', () => {
23
+ let fixture: ComponentFixture<TestHostComponent>;
24
+
25
+ beforeEach(async () => {
26
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
27
+ fixture = TestBed.createComponent(TestHostComponent);
28
+ fixture.detectChanges();
29
+ await fixture.whenStable();
30
+ fixture.detectChanges();
31
+ });
32
+
33
+ it('should render with region role', () => {
34
+ const el = fixture.nativeElement.querySelector('[snyCarousel]');
35
+ expect(el.getAttribute('role')).toBe('region');
36
+ expect(el.getAttribute('aria-roledescription')).toBe('carousel');
37
+ });
38
+
39
+ it('should render slides with group role', () => {
40
+ const items = fixture.nativeElement.querySelectorAll('[snyCarouselItem]');
41
+ expect(items.length).toBe(3);
42
+ expect(items[0].getAttribute('role')).toBe('group');
43
+ });
44
+
45
+ it('should detect items via contentChildren', () => {
46
+ const c = fixture.componentInstance.carousel()!;
47
+ expect(c.totalItems()).toBe(3);
48
+ });
49
+
50
+ it('should navigate to next', () => {
51
+ const c = fixture.componentInstance.carousel()!;
52
+ expect(c.currentIndex()).toBe(0);
53
+ c.next();
54
+ expect(c.currentIndex()).toBe(1);
55
+ });
56
+
57
+ it('should loop around', () => {
58
+ const c = fixture.componentInstance.carousel()!;
59
+ c.goTo(2);
60
+ c.next();
61
+ expect(c.currentIndex()).toBe(0);
62
+ });
63
+
64
+ it('should navigate prev with loop', () => {
65
+ const c = fixture.componentInstance.carousel()!;
66
+ c.prev();
67
+ expect(c.currentIndex()).toBe(2);
68
+ });
69
+
70
+ it('should navigate next on ArrowRight keydown', () => {
71
+ const c = fixture.componentInstance.carousel()!;
72
+ const host = fixture.nativeElement.querySelector('[snyCarousel]');
73
+ expect(c.currentIndex()).toBe(0);
74
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
75
+ expect(c.currentIndex()).toBe(1);
76
+ });
77
+
78
+ it('should navigate prev on ArrowLeft keydown', () => {
79
+ const c = fixture.componentInstance.carousel()!;
80
+ const host = fixture.nativeElement.querySelector('[snyCarousel]');
81
+ c.goTo(1);
82
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
83
+ expect(c.currentIndex()).toBe(0);
84
+ });
85
+ });