@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,84 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ @Directive({
5
+ selector: 'nav[snyBreadcrumb]',
6
+ standalone: true,
7
+ host: {
8
+ '[class]': 'computedClass()',
9
+ 'aria-label': 'Breadcrumb',
10
+ },
11
+ })
12
+ export class SnyBreadcrumbDirective {
13
+ readonly class = input<string>('');
14
+ protected readonly computedClass = computed(() => cn('', this.class()));
15
+ }
16
+
17
+ @Directive({
18
+ selector: 'ol[snyBreadcrumbList]',
19
+ standalone: true,
20
+ host: { '[class]': 'computedClass()' },
21
+ })
22
+ export class SnyBreadcrumbListDirective {
23
+ readonly class = input<string>('');
24
+ protected readonly computedClass = computed(() =>
25
+ cn('flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5', this.class())
26
+ );
27
+ }
28
+
29
+ @Directive({
30
+ selector: 'li[snyBreadcrumbItem]',
31
+ standalone: true,
32
+ host: { '[class]': 'computedClass()' },
33
+ })
34
+ export class SnyBreadcrumbItemDirective {
35
+ readonly class = input<string>('');
36
+ protected readonly computedClass = computed(() =>
37
+ cn('inline-flex items-center gap-1.5', this.class())
38
+ );
39
+ }
40
+
41
+ @Directive({
42
+ selector: '[snyBreadcrumbLink]',
43
+ standalone: true,
44
+ host: { '[class]': 'computedClass()' },
45
+ })
46
+ export class SnyBreadcrumbLinkDirective {
47
+ readonly class = input<string>('');
48
+ protected readonly computedClass = computed(() =>
49
+ cn('transition-colors hover:text-foreground', this.class())
50
+ );
51
+ }
52
+
53
+ @Directive({
54
+ selector: '[snyBreadcrumbSeparator]',
55
+ standalone: true,
56
+ host: {
57
+ role: 'presentation',
58
+ '[aria-hidden]': 'true',
59
+ '[class]': 'computedClass()',
60
+ },
61
+ })
62
+ export class SnyBreadcrumbSeparatorDirective {
63
+ readonly class = input<string>('');
64
+ protected readonly computedClass = computed(() =>
65
+ cn('[&>svg]:size-3.5', this.class())
66
+ );
67
+ }
68
+
69
+ @Directive({
70
+ selector: '[snyBreadcrumbPage]',
71
+ standalone: true,
72
+ host: {
73
+ role: 'link',
74
+ 'aria-disabled': 'true',
75
+ '[attr.aria-current]': '"page"',
76
+ '[class]': 'computedClass()',
77
+ },
78
+ })
79
+ export class SnyBreadcrumbPageDirective {
80
+ readonly class = input<string>('');
81
+ protected readonly computedClass = computed(() =>
82
+ cn('font-normal text-foreground', this.class())
83
+ );
84
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ SnyBreadcrumbDirective,
3
+ SnyBreadcrumbListDirective,
4
+ SnyBreadcrumbItemDirective,
5
+ SnyBreadcrumbLinkDirective,
6
+ SnyBreadcrumbSeparatorDirective,
7
+ SnyBreadcrumbPageDirective,
8
+ } from './breadcrumb.directives';
@@ -0,0 +1,92 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnyButtonDirective } from './button.directive';
4
+ import type { ButtonVariant, ButtonSize } from './button.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyButtonDirective],
9
+ template: `<button snyBtn [variant]="variant()" [size]="size()" [disabled]="disabled()" [loading]="loading()">Click</button>`,
10
+ })
11
+ class TestHostComponent {
12
+ variant = signal<ButtonVariant>('default');
13
+ size = signal<ButtonSize>('md');
14
+ disabled = signal(false);
15
+ loading = signal(false);
16
+ }
17
+
18
+ describe('SnyButtonDirective', () => {
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 variant classes', () => {
33
+ expect(button.className).toContain('bg-primary');
34
+ expect(button.className).toContain('text-primary-foreground');
35
+ });
36
+
37
+ it('should apply destructive variant', () => {
38
+ fixture.componentInstance.variant.set('destructive');
39
+ fixture.detectChanges();
40
+ expect(button.className).toContain('bg-destructive');
41
+ });
42
+
43
+ it('should apply outline variant', () => {
44
+ fixture.componentInstance.variant.set('outline');
45
+ fixture.detectChanges();
46
+ expect(button.className).toContain('border');
47
+ expect(button.className).toContain('bg-background');
48
+ });
49
+
50
+ it('should apply ghost variant', () => {
51
+ fixture.componentInstance.variant.set('ghost');
52
+ fixture.detectChanges();
53
+ expect(button.className).toContain('hover:bg-accent');
54
+ });
55
+
56
+ it('should apply sm size', () => {
57
+ fixture.componentInstance.size.set('sm');
58
+ fixture.detectChanges();
59
+ expect(button.className).toContain('h-9');
60
+ });
61
+
62
+ it('should apply lg size', () => {
63
+ fixture.componentInstance.size.set('lg');
64
+ fixture.detectChanges();
65
+ expect(button.className).toContain('h-11');
66
+ });
67
+
68
+ it('should apply icon size', () => {
69
+ fixture.componentInstance.size.set('icon');
70
+ fixture.detectChanges();
71
+ expect(button.className).toContain('w-10');
72
+ });
73
+
74
+ it('should set aria-disabled when disabled', () => {
75
+ fixture.componentInstance.disabled.set(true);
76
+ fixture.detectChanges();
77
+ expect(button.getAttribute('aria-disabled')).toBe('true');
78
+ });
79
+
80
+ it('should set aria-disabled when loading', () => {
81
+ fixture.componentInstance.loading.set(true);
82
+ fixture.detectChanges();
83
+ expect(button.getAttribute('aria-disabled')).toBe('true');
84
+ expect(button.className).toContain('cursor-wait');
85
+ });
86
+
87
+ it('should set tabindex=-1 when disabled', () => {
88
+ fixture.componentInstance.disabled.set(true);
89
+ fixture.detectChanges();
90
+ expect(button.getAttribute('tabindex')).toBe('-1');
91
+ });
92
+ });
@@ -0,0 +1,29 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { buttonVariants, type ButtonVariant, type ButtonSize } from './button.variants';
4
+
5
+ @Directive({
6
+ selector: 'button[snyBtn], a[snyBtn]',
7
+ standalone: true,
8
+ host: {
9
+ '[class]': 'computedClass()',
10
+ '[attr.aria-disabled]': 'disabled() || loading() || null',
11
+ '[attr.disabled]': 'disabled() || loading() || null',
12
+ '[attr.tabindex]': '(disabled() || loading()) ? -1 : null',
13
+ },
14
+ })
15
+ export class SnyButtonDirective {
16
+ readonly variant = input<ButtonVariant>('default');
17
+ readonly size = input<ButtonSize>('md');
18
+ readonly disabled = input(false);
19
+ readonly loading = input(false);
20
+ readonly class = input<string>('');
21
+
22
+ protected readonly computedClass = computed(() =>
23
+ cn(
24
+ buttonVariants({ variant: this.variant(), size: this.size() }),
25
+ this.loading() && 'relative cursor-wait',
26
+ this.class()
27
+ )
28
+ );
29
+ }
@@ -0,0 +1,30 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const buttonVariants = cva(
4
+ 'inline-flex items-center justify-center gap-2 whitespace-nowrap 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
9
+ destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
10
+ outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground',
11
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
12
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
13
+ link: 'text-primary underline-offset-4 hover:underline',
14
+ },
15
+ size: {
16
+ sm: 'h-9 rounded-sm px-3',
17
+ md: 'h-10 px-4 py-2',
18
+ lg: 'h-11 rounded-sm px-8',
19
+ icon: 'h-10 w-10',
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: 'default',
24
+ size: 'md',
25
+ },
26
+ }
27
+ );
28
+
29
+ export type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
30
+ export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
@@ -0,0 +1,2 @@
1
+ export { SnyButtonDirective } from './button.directive';
2
+ export { buttonVariants, type ButtonVariant, type ButtonSize } from './button.variants';
@@ -0,0 +1,46 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnyButtonGroupDirective } from './button-group.directive';
4
+ import type { ButtonGroupOrientation } from './button-group.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyButtonGroupDirective],
9
+ template: `<div snyButtonGroup [orientation]="orientation()"><button>A</button><button>B</button></div>`,
10
+ })
11
+ class TestHostComponent {
12
+ orientation = signal<ButtonGroupOrientation>('horizontal');
13
+ }
14
+
15
+ describe('SnyButtonGroupDirective', () => {
16
+ let fixture: ComponentFixture<TestHostComponent>;
17
+ let el: HTMLElement;
18
+
19
+ beforeEach(async () => {
20
+ await TestBed.configureTestingModule({
21
+ imports: [TestHostComponent],
22
+ }).compileComponents();
23
+
24
+ fixture = TestBed.createComponent(TestHostComponent);
25
+ fixture.detectChanges();
26
+ el = fixture.nativeElement.querySelector('[snyButtonGroup]');
27
+ });
28
+
29
+ it('should have role="group"', () => {
30
+ expect(el.getAttribute('role')).toBe('group');
31
+ });
32
+
33
+ it('should apply inline-flex', () => {
34
+ expect(el.className).toContain('inline-flex');
35
+ });
36
+
37
+ it('should apply horizontal orientation by default', () => {
38
+ expect(el.className).toContain('flex-row');
39
+ });
40
+
41
+ it('should apply vertical orientation', () => {
42
+ fixture.componentInstance.orientation.set('vertical');
43
+ fixture.detectChanges();
44
+ expect(el.className).toContain('flex-col');
45
+ });
46
+ });
@@ -0,0 +1,20 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { buttonGroupVariants, type ButtonGroupOrientation } from './button-group.variants';
4
+
5
+ @Directive({
6
+ selector: '[snyButtonGroup]',
7
+ standalone: true,
8
+ host: {
9
+ role: 'group',
10
+ '[class]': 'computedClass()',
11
+ },
12
+ })
13
+ export class SnyButtonGroupDirective {
14
+ readonly orientation = input<ButtonGroupOrientation>('horizontal');
15
+ readonly class = input<string>('');
16
+
17
+ protected readonly computedClass = computed(() =>
18
+ cn(buttonGroupVariants({ orientation: this.orientation() }), this.class())
19
+ );
20
+ }
@@ -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';