@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,60 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyStatDirective, SnyStatTitleDirective, SnyStatValueDirective, SnyStatDescriptionDirective, type StatDescriptionVariant } from './stat.directives';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyStatDirective, SnyStatTitleDirective, SnyStatValueDirective, SnyStatDescriptionDirective],
8
+ template: `
9
+ <div snyStat>
10
+ <div snyStatTitle>Total Revenue</div>
11
+ <div snyStatValue>$45,231</div>
12
+ <div snyStatDescription [variant]="descVariant()">+20.1% from last month</div>
13
+ </div>
14
+ `,
15
+ })
16
+ class TestHostComponent {
17
+ descVariant = signal<StatDescriptionVariant>('default');
18
+ }
19
+
20
+ describe('SnyStatDirective', () => {
21
+ let fixture: ComponentFixture<TestHostComponent>;
22
+
23
+ beforeEach(async () => {
24
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
25
+ fixture = TestBed.createComponent(TestHostComponent);
26
+ fixture.detectChanges();
27
+ });
28
+
29
+ it('should render stat sections', () => {
30
+ expect(fixture.nativeElement.querySelector('[snyStat]')).toBeTruthy();
31
+ expect(fixture.nativeElement.querySelector('[snyStatTitle]')).toBeTruthy();
32
+ expect(fixture.nativeElement.querySelector('[snyStatValue]')).toBeTruthy();
33
+ expect(fixture.nativeElement.querySelector('[snyStatDescription]')).toBeTruthy();
34
+ });
35
+
36
+ it('should apply success variant to description', () => {
37
+ fixture.componentInstance.descVariant.set('success');
38
+ fixture.detectChanges();
39
+ const desc = fixture.nativeElement.querySelector('[snyStatDescription]');
40
+ expect(desc.className).toContain('text-green-600');
41
+ });
42
+
43
+ it('should apply error variant to description', () => {
44
+ fixture.componentInstance.descVariant.set('error');
45
+ fixture.detectChanges();
46
+ const desc = fixture.nativeElement.querySelector('[snyStatDescription]');
47
+ expect(desc.className).toContain('text-destructive');
48
+ });
49
+
50
+ it('should have auto-generated id on title', () => {
51
+ const title = fixture.nativeElement.querySelector('[snyStatTitle]');
52
+ expect(title.id).toContain('sny-stat-title-');
53
+ });
54
+
55
+ it('should set aria-labelledby on value pointing to title id', () => {
56
+ const title = fixture.nativeElement.querySelector('[snyStatTitle]');
57
+ const value = fixture.nativeElement.querySelector('[snyStatValue]');
58
+ expect(value.getAttribute('aria-labelledby')).toBe(title.id);
59
+ });
60
+ });
@@ -0,0 +1,84 @@
1
+ import { Directive, InjectionToken, computed, inject, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ export const SNY_STAT = new InjectionToken<SnyStatDirective>('SnyStat');
5
+
6
+ let statIdCounter = 0;
7
+
8
+ @Directive({
9
+ selector: '[snyStat]',
10
+ standalone: true,
11
+ providers: [{ provide: SNY_STAT, useExisting: SnyStatDirective }],
12
+ host: { '[class]': 'computedClass()' },
13
+ })
14
+ export class SnyStatDirective {
15
+ readonly class = input<string>('');
16
+ readonly titleId = `sny-stat-title-${++statIdCounter}`;
17
+ protected readonly computedClass = computed(() =>
18
+ cn('flex flex-col gap-0.5', this.class())
19
+ );
20
+ }
21
+
22
+ @Directive({
23
+ selector: '[snyStatTitle]',
24
+ standalone: true,
25
+ host: {
26
+ '[class]': 'computedClass()',
27
+ '[id]': 'stat.titleId',
28
+ },
29
+ })
30
+ export class SnyStatTitleDirective {
31
+ readonly class = input<string>('');
32
+ readonly stat = inject(SNY_STAT);
33
+ protected readonly computedClass = computed(() =>
34
+ cn('text-sm text-muted-foreground', this.class())
35
+ );
36
+ }
37
+
38
+ @Directive({
39
+ selector: '[snyStatValue]',
40
+ standalone: true,
41
+ host: {
42
+ '[class]': 'computedClass()',
43
+ '[attr.aria-labelledby]': 'stat.titleId',
44
+ },
45
+ })
46
+ export class SnyStatValueDirective {
47
+ readonly class = input<string>('');
48
+ readonly stat = inject(SNY_STAT);
49
+ protected readonly computedClass = computed(() =>
50
+ cn('text-2xl font-bold', this.class())
51
+ );
52
+ }
53
+
54
+ export type StatDescriptionVariant = 'default' | 'success' | 'error';
55
+
56
+ @Directive({
57
+ selector: '[snyStatDescription]',
58
+ standalone: true,
59
+ host: { '[class]': 'computedClass()' },
60
+ })
61
+ export class SnyStatDescriptionDirective {
62
+ readonly variant = input<StatDescriptionVariant>('default');
63
+ readonly class = input<string>('');
64
+ protected readonly computedClass = computed(() => {
65
+ const v = this.variant();
66
+ const variantClass =
67
+ v === 'success' ? 'text-green-600 dark:text-green-400' :
68
+ v === 'error' ? 'text-destructive' :
69
+ 'text-muted-foreground';
70
+ return cn('text-xs', variantClass, this.class());
71
+ });
72
+ }
73
+
74
+ @Directive({
75
+ selector: '[snyStatFigure]',
76
+ standalone: true,
77
+ host: { '[class]': 'computedClass()' },
78
+ })
79
+ export class SnyStatFigureDirective {
80
+ readonly class = input<string>('');
81
+ protected readonly computedClass = computed(() =>
82
+ cn('text-muted-foreground', this.class())
83
+ );
84
+ }
@@ -0,0 +1,2 @@
1
+ export { SnyStatusDirective } from './status.directive';
2
+ export { statusVariants, type StatusVariant, type StatusSize } from './status.variants';
@@ -0,0 +1,43 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyStatusDirective } from './status.directive';
4
+ import type { StatusVariant } from './status.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyStatusDirective],
9
+ template: `<span snyStatus [variant]="variant()" [pulse]="pulse()"></span>`,
10
+ })
11
+ class TestHostComponent {
12
+ variant = signal<StatusVariant>('default');
13
+ pulse = signal(false);
14
+ }
15
+
16
+ describe('SnyStatusDirective', () => {
17
+ let fixture: ComponentFixture<TestHostComponent>;
18
+ let el: HTMLElement;
19
+
20
+ beforeEach(async () => {
21
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
22
+ fixture = TestBed.createComponent(TestHostComponent);
23
+ fixture.detectChanges();
24
+ el = fixture.nativeElement.querySelector('[snyStatus]');
25
+ });
26
+
27
+ it('should have status role', () => {
28
+ expect(el.getAttribute('role')).toBe('status');
29
+ });
30
+
31
+ it('should apply success variant', () => {
32
+ fixture.componentInstance.variant.set('success');
33
+ fixture.detectChanges();
34
+ expect(el.className).toContain('bg-green-500');
35
+ expect(el.getAttribute('aria-label')).toBe('Online');
36
+ });
37
+
38
+ it('should apply pulse animation', () => {
39
+ fixture.componentInstance.pulse.set(true);
40
+ fixture.detectChanges();
41
+ expect(el.className).toContain('animate-pulse');
42
+ });
43
+ });
@@ -0,0 +1,38 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { statusVariants, type StatusVariant, type StatusSize } from './status.variants';
4
+
5
+ const variantLabels: Record<StatusVariant, string> = {
6
+ default: 'Active',
7
+ success: 'Online',
8
+ warning: 'Away',
9
+ error: 'Error',
10
+ info: 'Info',
11
+ neutral: 'Offline',
12
+ };
13
+
14
+ @Directive({
15
+ selector: '[snyStatus]',
16
+ standalone: true,
17
+ host: {
18
+ 'role': 'status',
19
+ '[attr.aria-label]': 'ariaLabel()',
20
+ '[class]': 'computedClass()',
21
+ },
22
+ })
23
+ export class SnyStatusDirective {
24
+ readonly variant = input<StatusVariant>('default');
25
+ readonly size = input<StatusSize>('md');
26
+ readonly pulse = input(false);
27
+ readonly class = input<string>('');
28
+
29
+ protected readonly ariaLabel = computed(() => variantLabels[this.variant()]);
30
+
31
+ protected readonly computedClass = computed(() =>
32
+ cn(
33
+ statusVariants({ variant: this.variant(), size: this.size() }),
34
+ this.pulse() && 'animate-pulse',
35
+ this.class()
36
+ )
37
+ );
38
+ }
@@ -0,0 +1,26 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const statusVariants = cva('inline-block rounded-full', {
4
+ variants: {
5
+ variant: {
6
+ default: 'bg-primary',
7
+ success: 'bg-green-500',
8
+ warning: 'bg-yellow-500',
9
+ error: 'bg-destructive',
10
+ info: 'bg-blue-500',
11
+ neutral: 'bg-muted-foreground',
12
+ },
13
+ size: {
14
+ xs: 'h-1.5 w-1.5',
15
+ sm: 'h-2 w-2',
16
+ md: 'h-2.5 w-2.5',
17
+ },
18
+ },
19
+ defaultVariants: {
20
+ variant: 'default',
21
+ size: 'md',
22
+ },
23
+ });
24
+
25
+ export type StatusVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'neutral';
26
+ export type StatusSize = 'xs' | 'sm' | 'md';
@@ -0,0 +1,8 @@
1
+ export {
2
+ SnyStepsDirective,
3
+ SnyStepDirective,
4
+ SNY_STEPS,
5
+ type StepStatus,
6
+ type StepsOrientation,
7
+ type StepsSize,
8
+ } from './steps.directives';
@@ -0,0 +1,52 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyStepsDirective, SnyStepDirective, type StepStatus } from './steps.directives';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyStepsDirective, SnyStepDirective],
8
+ template: `
9
+ <div snySteps>
10
+ <div snyStep status="completed">Register</div>
11
+ <div snyStep [status]="activeStatus()">Payment</div>
12
+ <div snyStep status="default">Confirm</div>
13
+ </div>
14
+ `,
15
+ })
16
+ class TestHostComponent {
17
+ activeStatus = signal<StepStatus>('active');
18
+ }
19
+
20
+ describe('SnyStepsDirective', () => {
21
+ let fixture: ComponentFixture<TestHostComponent>;
22
+
23
+ beforeEach(async () => {
24
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
25
+ fixture = TestBed.createComponent(TestHostComponent);
26
+ fixture.detectChanges();
27
+ });
28
+
29
+ it('should render with list role', () => {
30
+ const steps = fixture.nativeElement.querySelector('[snySteps]');
31
+ expect(steps.getAttribute('role')).toBe('list');
32
+ });
33
+
34
+ it('should render steps with listitem role', () => {
35
+ const items = fixture.nativeElement.querySelectorAll('[snyStep]');
36
+ expect(items.length).toBe(3);
37
+ items.forEach((item: HTMLElement) => {
38
+ expect(item.getAttribute('role')).toBe('listitem');
39
+ });
40
+ });
41
+
42
+ it('should mark active step with aria-current', () => {
43
+ const items = fixture.nativeElement.querySelectorAll('[snyStep]');
44
+ expect(items[1].getAttribute('aria-current')).toBe('step');
45
+ expect(items[0].getAttribute('aria-current')).toBeNull();
46
+ });
47
+
48
+ it('should apply completed styling', () => {
49
+ const items = fixture.nativeElement.querySelectorAll('[snyStep]');
50
+ expect(items[0].className).toContain('text-primary');
51
+ });
52
+ });
@@ -0,0 +1,80 @@
1
+ import { Directive, InjectionToken, computed, contentChildren, inject, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ export const SNY_STEPS = new InjectionToken<SnyStepsDirective>('SnySteps');
5
+
6
+ export type StepStatus = 'default' | 'active' | 'completed' | 'error';
7
+ export type StepsOrientation = 'horizontal' | 'vertical';
8
+ export type StepsSize = 'sm' | 'md' | 'lg';
9
+
10
+ @Directive({
11
+ selector: '[snyStep]',
12
+ standalone: true,
13
+ host: {
14
+ 'role': 'listitem',
15
+ '[class]': 'computedClass()',
16
+ '[attr.aria-current]': 'status() === "active" ? "step" : null',
17
+ },
18
+ })
19
+ export class SnyStepDirective {
20
+ readonly status = input<StepStatus>('default');
21
+ readonly icon = input<string>('');
22
+ readonly class = input<string>('');
23
+
24
+ protected readonly computedClass = computed(() => {
25
+ const s = this.status();
26
+ const base = 'flex items-center gap-2';
27
+
28
+ // Step indicator via before: pseudo-element
29
+ const indicatorBase = 'before:flex before:items-center before:justify-center before:w-8 before:h-8 before:rounded-full before:border-2 before:text-xs before:shrink-0';
30
+
31
+ const statusIndicator =
32
+ s === 'completed' ? 'before:bg-primary before:border-primary before:text-primary-foreground before:content-["✓"]' :
33
+ s === 'active' ? 'before:border-primary before:text-primary before:content-["●"]' :
34
+ s === 'error' ? 'before:border-destructive before:text-destructive before:content-["!"]' :
35
+ 'before:border-muted-foreground/30 before:text-muted-foreground before:content-["○"]';
36
+
37
+ const statusClass =
38
+ s === 'completed' ? 'text-primary' :
39
+ s === 'active' ? 'text-primary font-medium' :
40
+ s === 'error' ? 'text-destructive' :
41
+ 'text-muted-foreground';
42
+
43
+ return cn(base, indicatorBase, statusIndicator, statusClass, this.class());
44
+ });
45
+ }
46
+
47
+ @Directive({
48
+ selector: '[snySteps]',
49
+ standalone: true,
50
+ exportAs: 'snySteps',
51
+ providers: [{ provide: SNY_STEPS, useExisting: SnyStepsDirective }],
52
+ host: {
53
+ 'role': 'list',
54
+ 'aria-label': 'Progress steps',
55
+ '[class]': 'computedClass()',
56
+ },
57
+ })
58
+ export class SnyStepsDirective {
59
+ readonly orientation = input<StepsOrientation>('horizontal');
60
+ readonly size = input<StepsSize>('md');
61
+ readonly class = input<string>('');
62
+
63
+ readonly steps = contentChildren(SnyStepDirective);
64
+
65
+ readonly activeIndex = computed(() => {
66
+ const s = this.steps();
67
+ return s.findIndex((step) => step.status() === 'active');
68
+ });
69
+
70
+ protected readonly computedClass = computed(() => {
71
+ const o = this.orientation();
72
+ const s = this.size();
73
+ const base = 'flex';
74
+ const orientationClass = o === 'horizontal'
75
+ ? 'flex-row items-center gap-2 [&>*+*]:before:content-[""] [&>*+*]:before:flex-1 [&>*+*]:before:h-px [&>*+*]:before:bg-border [&>*+*]:before:mx-2 [&>*+*]:before:min-w-[2rem]'
76
+ : 'flex-col gap-2 [&>*+*]:before:content-[""] [&>*+*]:before:w-px [&>*+*]:before:h-4 [&>*+*]:before:bg-border [&>*+*]:before:ml-4';
77
+ const sizeClass = s === 'sm' ? 'text-xs' : s === 'lg' ? 'text-base' : 'text-sm';
78
+ return cn(base, orientationClass, sizeClass, this.class());
79
+ });
80
+ }
@@ -0,0 +1,2 @@
1
+ export { SnySwitchComponent } from './switch.component';
2
+ export { switchTrackVariants, type SwitchSize } from './switch.variants';
@@ -0,0 +1,98 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
4
+ import { SnySwitchComponent } from './switch.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnySwitchComponent],
9
+ template: `<sny-switch [(checked)]="checked" [disabled]="disabled()" />`,
10
+ })
11
+ class TestHostComponent {
12
+ checked = signal(false);
13
+ disabled = signal(false);
14
+ }
15
+
16
+ describe('SnySwitchComponent', () => {
17
+ let fixture: ComponentFixture<TestHostComponent>;
18
+ let button: HTMLButtonElement;
19
+
20
+ beforeEach(async () => {
21
+ await TestBed.configureTestingModule({
22
+ imports: [TestHostComponent],
23
+ }).compileComponents();
24
+
25
+ fixture = TestBed.createComponent(TestHostComponent);
26
+ fixture.detectChanges();
27
+ button = fixture.nativeElement.querySelector('button');
28
+ });
29
+
30
+ it('should have switch role', () => {
31
+ expect(button.getAttribute('role')).toBe('switch');
32
+ });
33
+
34
+ it('should be unchecked by default', () => {
35
+ expect(button.getAttribute('aria-checked')).toBe('false');
36
+ expect(button.className).toContain('bg-input');
37
+ });
38
+
39
+ it('should toggle on click', () => {
40
+ button.click();
41
+ fixture.detectChanges();
42
+ expect(button.getAttribute('aria-checked')).toBe('true');
43
+ expect(button.className).toContain('bg-primary');
44
+ });
45
+
46
+ it('should not toggle when disabled', () => {
47
+ fixture.componentInstance.disabled.set(true);
48
+ fixture.detectChanges();
49
+ expect(button.disabled).toBe(true);
50
+ });
51
+ });
52
+
53
+ @Component({
54
+ standalone: true,
55
+ imports: [ReactiveFormsModule, SnySwitchComponent],
56
+ template: `<sny-switch [formControl]="ctrl" />`,
57
+ })
58
+ class ReactiveFormHost {
59
+ ctrl = new FormControl(false);
60
+ }
61
+
62
+ describe('SnySwitchComponent — Reactive Forms', () => {
63
+ let fixture: ComponentFixture<ReactiveFormHost>;
64
+ let button: HTMLButtonElement;
65
+
66
+ beforeEach(async () => {
67
+ await TestBed.configureTestingModule({
68
+ imports: [ReactiveFormHost],
69
+ }).compileComponents();
70
+ fixture = TestBed.createComponent(ReactiveFormHost);
71
+ fixture.detectChanges();
72
+ button = fixture.nativeElement.querySelector('button');
73
+ });
74
+
75
+ it('should update view when FormControl value changes (writeValue)', () => {
76
+ fixture.componentInstance.ctrl.setValue(true);
77
+ fixture.detectChanges();
78
+ expect(button.getAttribute('aria-checked')).toBe('true');
79
+ });
80
+
81
+ it('should update FormControl when user interacts (onChange)', () => {
82
+ button.click();
83
+ fixture.detectChanges();
84
+ expect(fixture.componentInstance.ctrl.value).toBe(true);
85
+ });
86
+
87
+ it('should disable via FormControl.disable() (setDisabledState)', () => {
88
+ fixture.componentInstance.ctrl.disable();
89
+ fixture.detectChanges();
90
+ expect(button.disabled).toBe(true);
91
+ });
92
+
93
+ it('should mark as touched on blur (onTouched)', () => {
94
+ expect(fixture.componentInstance.ctrl.touched).toBe(false);
95
+ button.dispatchEvent(new Event('blur'));
96
+ expect(fixture.componentInstance.ctrl.touched).toBe(true);
97
+ });
98
+ });
@@ -0,0 +1,84 @@
1
+ import { ChangeDetectionStrategy, Component, computed, effect, forwardRef, input, model, signal } from '@angular/core';
2
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
3
+ import { cn } from '../core/utils/cn';
4
+ import { switchTrackVariants, switchThumbSize, switchThumbTranslate, type SwitchSize } from './switch.variants';
5
+
6
+ @Component({
7
+ selector: 'sny-switch',
8
+ standalone: true,
9
+ changeDetection: ChangeDetectionStrategy.OnPush,
10
+ host: { class: 'inline-flex' },
11
+ providers: [
12
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnySwitchComponent), multi: true },
13
+ ],
14
+ template: `
15
+ <button
16
+ type="button"
17
+ role="switch"
18
+ [attr.aria-checked]="checked()"
19
+ [disabled]="isDisabled()"
20
+ [class]="trackClass()"
21
+ (click)="checked.set(!checked())"
22
+ (blur)="onTouched()"
23
+ >
24
+ <span [class]="thumbClass()"></span>
25
+ </button>
26
+ `,
27
+ })
28
+ export class SnySwitchComponent implements ControlValueAccessor {
29
+ readonly checked = model(false);
30
+ readonly disabled = input(false);
31
+ readonly size = input<SwitchSize>('md');
32
+ readonly class = input<string>('');
33
+
34
+ private readonly _disabledByCva = signal(false);
35
+ protected readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
36
+
37
+ private _onChange: (value: boolean) => void = () => {};
38
+ protected onTouched: () => void = () => {};
39
+ private _writing = false;
40
+
41
+ constructor() {
42
+ effect(() => {
43
+ const val = this.checked();
44
+ if (this._writing) {
45
+ this._writing = false;
46
+ return;
47
+ }
48
+ this._onChange(val);
49
+ });
50
+ }
51
+
52
+ writeValue(val: boolean): void {
53
+ this._writing = true;
54
+ this.checked.set(val ?? false);
55
+ }
56
+
57
+ registerOnChange(fn: (value: boolean) => void): void {
58
+ this._onChange = fn;
59
+ }
60
+
61
+ registerOnTouched(fn: () => void): void {
62
+ this.onTouched = fn;
63
+ }
64
+
65
+ setDisabledState(isDisabled: boolean): void {
66
+ this._disabledByCva.set(isDisabled);
67
+ }
68
+
69
+ protected readonly trackClass = computed(() =>
70
+ cn(
71
+ switchTrackVariants({ size: this.size() }),
72
+ this.checked() ? 'bg-primary' : 'bg-input',
73
+ this.class()
74
+ )
75
+ );
76
+
77
+ protected readonly thumbClass = computed(() =>
78
+ cn(
79
+ 'pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform',
80
+ switchThumbSize[this.size()],
81
+ this.checked() ? switchThumbTranslate[this.size()] : 'translate-x-0'
82
+ )
83
+ );
84
+ }
@@ -0,0 +1,31 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const switchTrackVariants = cva(
4
+ 'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: 'h-5 w-9',
9
+ md: 'h-6 w-11',
10
+ lg: 'h-7 w-14',
11
+ },
12
+ },
13
+ defaultVariants: {
14
+ size: 'md',
15
+ },
16
+ }
17
+ );
18
+
19
+ export const switchThumbSize: Record<string, string> = {
20
+ sm: 'h-4 w-4',
21
+ md: 'h-5 w-5',
22
+ lg: 'h-6 w-6',
23
+ };
24
+
25
+ export const switchThumbTranslate: Record<string, string> = {
26
+ sm: 'translate-x-4',
27
+ md: 'translate-x-5',
28
+ lg: 'translate-x-7',
29
+ };
30
+
31
+ export type SwitchSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,12 @@
1
+ export {
2
+ SnyTableDirective,
3
+ SnyTableHeaderDirective,
4
+ SnyTableBodyDirective,
5
+ SnyTableRowDirective,
6
+ SnyTableHeadDirective,
7
+ SnyTableCellDirective,
8
+ SnyTableFooterDirective,
9
+ SnyTableCaptionDirective,
10
+ SNY_TABLE,
11
+ } from './table.directives';
12
+ export { tableVariants, tableCellVariants, type TableVariant, type TableDensity } from './table.variants';