@sonny-ui/core 0.1.0-alpha.2 → 0.1.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/README.md +187 -40
  2. package/fesm2022/sonny-ui-core.mjs +6646 -272
  3. package/fesm2022/sonny-ui-core.mjs.map +1 -1
  4. package/package.json +8 -5
  5. package/schematics/ng-add/index.js +27 -0
  6. package/schematics/ng-add/schema.json +1 -1
  7. package/schematics/ng-generate/component/index.js +182 -1
  8. package/schematics/ng-generate/component/schema.json +2 -2
  9. package/src/lib/accordion/accordion.directives.spec.ts +173 -0
  10. package/src/lib/accordion/accordion.directives.ts +143 -0
  11. package/src/lib/accordion/index.ts +8 -0
  12. package/src/lib/alert/alert.directives.spec.ts +154 -0
  13. package/src/lib/alert/alert.directives.ts +67 -0
  14. package/src/lib/alert/alert.variants.ts +25 -0
  15. package/src/lib/alert/index.ts +6 -0
  16. package/src/lib/avatar/avatar.component.spec.ts +75 -0
  17. package/src/lib/avatar/avatar.component.ts +43 -0
  18. package/src/lib/avatar/avatar.variants.ts +26 -0
  19. package/src/lib/avatar/index.ts +2 -0
  20. package/src/lib/avatar-group/avatar-group.component.spec.ts +74 -0
  21. package/src/lib/avatar-group/avatar-group.component.ts +88 -0
  22. package/src/lib/avatar-group/index.ts +1 -0
  23. package/src/lib/badge/badge.directive.spec.ts +74 -0
  24. package/src/lib/badge/badge.directive.ts +17 -0
  25. package/src/lib/badge/badge.variants.ts +29 -0
  26. package/src/lib/badge/index.ts +2 -0
  27. package/src/lib/breadcrumb/breadcrumb.directives.spec.ts +80 -0
  28. package/src/lib/breadcrumb/breadcrumb.directives.ts +78 -0
  29. package/src/lib/breadcrumb/index.ts +8 -0
  30. package/src/lib/button/button.directive.spec.ts +92 -0
  31. package/src/lib/button/button.directive.ts +28 -0
  32. package/src/lib/button/button.variants.ts +30 -0
  33. package/src/lib/button/index.ts +2 -0
  34. package/src/lib/button-group/button-group.directive.spec.ts +46 -0
  35. package/src/lib/button-group/button-group.directive.ts +19 -0
  36. package/src/lib/button-group/button-group.variants.ts +18 -0
  37. package/src/lib/button-group/index.ts +2 -0
  38. package/src/lib/calendar/calendar.component.spec.ts +192 -0
  39. package/src/lib/calendar/calendar.component.ts +342 -0
  40. package/src/lib/calendar/calendar.types.ts +24 -0
  41. package/src/lib/calendar/index.ts +7 -0
  42. package/src/lib/card/card.directives.spec.ts +104 -0
  43. package/src/lib/card/card.directives.ts +72 -0
  44. package/src/lib/card/card.variants.ts +28 -0
  45. package/src/lib/card/index.ts +9 -0
  46. package/src/lib/carousel/carousel.directives.spec.ts +85 -0
  47. package/src/lib/carousel/carousel.directives.ts +159 -0
  48. package/src/lib/carousel/index.ts +8 -0
  49. package/src/lib/chat-bubble/chat-bubble.directives.spec.ts +52 -0
  50. package/src/lib/chat-bubble/chat-bubble.directives.ts +96 -0
  51. package/src/lib/chat-bubble/index.ts +11 -0
  52. package/src/lib/checkbox/checkbox.directive.spec.ts +57 -0
  53. package/src/lib/checkbox/checkbox.directive.ts +16 -0
  54. package/src/lib/checkbox/checkbox.variants.ts +19 -0
  55. package/src/lib/checkbox/index.ts +2 -0
  56. package/src/lib/color-picker/color-picker.component.spec.ts +328 -0
  57. package/src/lib/color-picker/color-picker.component.ts +537 -0
  58. package/src/lib/color-picker/color-picker.types.ts +24 -0
  59. package/src/lib/color-picker/color-picker.utils.ts +183 -0
  60. package/src/lib/color-picker/color-picker.variants.ts +17 -0
  61. package/src/lib/color-picker/index.ts +20 -0
  62. package/src/lib/combobox/combobox.component.spec.ts +151 -0
  63. package/src/lib/combobox/combobox.component.ts +264 -0
  64. package/src/lib/combobox/combobox.variants.ts +19 -0
  65. package/src/lib/combobox/index.ts +2 -0
  66. package/src/lib/command-palette/command-palette.component.spec.ts +178 -0
  67. package/src/lib/command-palette/command-palette.component.ts +194 -0
  68. package/src/lib/command-palette/command-palette.service.ts +36 -0
  69. package/src/lib/command-palette/command-palette.types.ts +23 -0
  70. package/src/lib/command-palette/index.ts +7 -0
  71. package/src/lib/data-table/data-table.component.spec.ts +443 -0
  72. package/src/lib/data-table/data-table.component.ts +602 -0
  73. package/src/lib/data-table/data-table.directives.ts +31 -0
  74. package/src/lib/data-table/data-table.types.ts +20 -0
  75. package/src/lib/data-table/index.ts +13 -0
  76. package/src/lib/date-picker/date-picker.component.spec.ts +131 -0
  77. package/src/lib/date-picker/date-picker.component.ts +220 -0
  78. package/src/lib/date-picker/date-picker.variants.ts +17 -0
  79. package/src/lib/date-picker/index.ts +2 -0
  80. package/src/lib/date-range-picker/date-range-picker.component.spec.ts +151 -0
  81. package/src/lib/date-range-picker/date-range-picker.component.ts +340 -0
  82. package/src/lib/date-range-picker/index.ts +1 -0
  83. package/src/lib/diff/diff.component.spec.ts +47 -0
  84. package/src/lib/diff/diff.component.ts +82 -0
  85. package/src/lib/diff/index.ts +1 -0
  86. package/src/lib/divider/divider.component.spec.ts +48 -0
  87. package/src/lib/divider/divider.component.ts +51 -0
  88. package/src/lib/divider/divider.variants.ts +22 -0
  89. package/src/lib/divider/index.ts +2 -0
  90. package/src/lib/dock/dock.directives.spec.ts +85 -0
  91. package/src/lib/dock/dock.directives.ts +81 -0
  92. package/src/lib/dock/index.ts +1 -0
  93. package/src/lib/drawer/drawer.directives.spec.ts +62 -0
  94. package/src/lib/drawer/drawer.directives.ts +80 -0
  95. package/src/lib/drawer/index.ts +8 -0
  96. package/src/lib/dropdown/dropdown.directives.spec.ts +106 -0
  97. package/src/lib/dropdown/dropdown.directives.ts +136 -0
  98. package/src/lib/dropdown/dropdown.variants.ts +27 -0
  99. package/src/lib/dropdown/index.ts +15 -0
  100. package/src/lib/fab/fab.directives.spec.ts +60 -0
  101. package/src/lib/fab/fab.directives.ts +77 -0
  102. package/src/lib/fab/index.ts +8 -0
  103. package/src/lib/fieldset/fieldset.directives.spec.ts +74 -0
  104. package/src/lib/fieldset/fieldset.directives.ts +49 -0
  105. package/src/lib/fieldset/fieldset.variants.ts +15 -0
  106. package/src/lib/fieldset/index.ts +6 -0
  107. package/src/lib/file-input/file-input.component.spec.ts +114 -0
  108. package/src/lib/file-input/file-input.component.ts +155 -0
  109. package/src/lib/file-input/file-input.variants.ts +25 -0
  110. package/src/lib/file-input/index.ts +6 -0
  111. package/src/lib/indicator/index.ts +6 -0
  112. package/src/lib/indicator/indicator.directives.spec.ts +64 -0
  113. package/src/lib/indicator/indicator.directives.ts +59 -0
  114. package/src/lib/input/index.ts +3 -0
  115. package/src/lib/input/input.directive.spec.ts +103 -0
  116. package/src/lib/input/input.directive.ts +25 -0
  117. package/src/lib/input/input.variants.ts +42 -0
  118. package/src/lib/input/label.directive.ts +16 -0
  119. package/src/lib/kbd/index.ts +2 -0
  120. package/src/lib/kbd/kbd.directive.spec.ts +42 -0
  121. package/src/lib/kbd/kbd.directive.ts +18 -0
  122. package/src/lib/kbd/kbd.variants.ts +19 -0
  123. package/src/lib/link/index.ts +2 -0
  124. package/src/lib/link/link.directive.spec.ts +41 -0
  125. package/src/lib/link/link.directive.ts +18 -0
  126. package/src/lib/link/link.variants.ts +20 -0
  127. package/src/lib/list/index.ts +8 -0
  128. package/src/lib/list/list.directives.spec.ts +65 -0
  129. package/src/lib/list/list.directives.ts +81 -0
  130. package/src/lib/loader/index.ts +2 -0
  131. package/src/lib/loader/loader.component.spec.ts +58 -0
  132. package/src/lib/loader/loader.component.ts +47 -0
  133. package/src/lib/loader/loader.variants.ts +21 -0
  134. package/src/lib/modal/dialog-ref.ts +19 -0
  135. package/src/lib/modal/dialog.directives.ts +84 -0
  136. package/src/lib/modal/dialog.service.spec.ts +52 -0
  137. package/src/lib/modal/dialog.service.ts +61 -0
  138. package/src/lib/modal/dialog.types.ts +16 -0
  139. package/src/lib/modal/index.ts +11 -0
  140. package/src/lib/navbar/index.ts +7 -0
  141. package/src/lib/navbar/navbar.directives.spec.ts +59 -0
  142. package/src/lib/navbar/navbar.directives.ts +57 -0
  143. package/src/lib/number-input/index.ts +2 -0
  144. package/src/lib/number-input/number-input.component.spec.ts +151 -0
  145. package/src/lib/number-input/number-input.component.ts +152 -0
  146. package/src/lib/number-input/number-input.variants.ts +17 -0
  147. package/src/lib/otp-input/index.ts +2 -0
  148. package/src/lib/otp-input/otp-input.component.spec.ts +252 -0
  149. package/src/lib/otp-input/otp-input.component.ts +274 -0
  150. package/src/lib/otp-input/otp-input.variants.ts +18 -0
  151. package/src/lib/pagination/index.ts +6 -0
  152. package/src/lib/pagination/pagination.component.spec.ts +59 -0
  153. package/src/lib/pagination/pagination.component.ts +143 -0
  154. package/src/lib/pagination/pagination.variants.ts +31 -0
  155. package/src/lib/popover/index.ts +6 -0
  156. package/src/lib/popover/popover.directives.spec.ts +147 -0
  157. package/src/lib/popover/popover.directives.ts +151 -0
  158. package/src/lib/progress/index.ts +7 -0
  159. package/src/lib/progress/progress.component.spec.ts +117 -0
  160. package/src/lib/progress/progress.component.ts +64 -0
  161. package/src/lib/progress/progress.variants.ts +43 -0
  162. package/src/lib/radial-progress/index.ts +5 -0
  163. package/src/lib/radial-progress/radial-progress.component.spec.ts +41 -0
  164. package/src/lib/radial-progress/radial-progress.component.ts +70 -0
  165. package/src/lib/radio/index.ts +2 -0
  166. package/src/lib/radio/radio.directive.spec.ts +46 -0
  167. package/src/lib/radio/radio.directive.ts +16 -0
  168. package/src/lib/radio/radio.variants.ts +19 -0
  169. package/src/lib/rating/index.ts +2 -0
  170. package/src/lib/rating/rating.component.spec.ts +157 -0
  171. package/src/lib/rating/rating.component.ts +163 -0
  172. package/src/lib/rating/rating.variants.ts +20 -0
  173. package/src/lib/select/index.ts +2 -0
  174. package/src/lib/select/select.component.spec.ts +112 -0
  175. package/src/lib/select/select.component.ts +235 -0
  176. package/src/lib/select/select.variants.ts +19 -0
  177. package/src/lib/sheet/index.ts +10 -0
  178. package/src/lib/sheet/sheet-ref.ts +18 -0
  179. package/src/lib/sheet/sheet.component.spec.ts +67 -0
  180. package/src/lib/sheet/sheet.directives.ts +70 -0
  181. package/src/lib/sheet/sheet.service.ts +100 -0
  182. package/src/lib/sheet/sheet.types.ts +23 -0
  183. package/src/lib/skeleton/index.ts +2 -0
  184. package/src/lib/skeleton/skeleton.directive.spec.ts +63 -0
  185. package/src/lib/skeleton/skeleton.directive.ts +21 -0
  186. package/src/lib/skeleton/skeleton.variants.ts +27 -0
  187. package/src/lib/slider/index.ts +2 -0
  188. package/src/lib/slider/slider.component.spec.ts +104 -0
  189. package/src/lib/slider/slider.component.ts +181 -0
  190. package/src/lib/slider/slider.variants.ts +25 -0
  191. package/src/lib/stat/index.ts +8 -0
  192. package/src/lib/stat/stat.directives.spec.ts +60 -0
  193. package/src/lib/stat/stat.directives.ts +79 -0
  194. package/src/lib/status/index.ts +2 -0
  195. package/src/lib/status/status.directive.spec.ts +43 -0
  196. package/src/lib/status/status.directive.ts +37 -0
  197. package/src/lib/status/status.variants.ts +26 -0
  198. package/src/lib/steps/index.ts +8 -0
  199. package/src/lib/steps/steps.directives.spec.ts +52 -0
  200. package/src/lib/steps/steps.directives.ts +78 -0
  201. package/src/lib/switch/index.ts +2 -0
  202. package/src/lib/switch/switch.component.spec.ts +98 -0
  203. package/src/lib/switch/switch.component.ts +76 -0
  204. package/src/lib/switch/switch.variants.ts +31 -0
  205. package/src/lib/table/index.ts +12 -0
  206. package/src/lib/table/table.directives.spec.ts +111 -0
  207. package/src/lib/table/table.directives.ts +126 -0
  208. package/src/lib/table/table.variants.ts +36 -0
  209. package/src/lib/tabs/index.ts +8 -0
  210. package/src/lib/tabs/tabs.directives.spec.ts +136 -0
  211. package/src/lib/tabs/tabs.directives.ts +126 -0
  212. package/src/lib/tabs/tabs.variants.ts +17 -0
  213. package/src/lib/tag-input/index.ts +2 -0
  214. package/src/lib/tag-input/tag-input.component.spec.ts +190 -0
  215. package/src/lib/tag-input/tag-input.component.ts +172 -0
  216. package/src/lib/tag-input/tag-input.variants.ts +31 -0
  217. package/src/lib/textarea/index.ts +7 -0
  218. package/src/lib/textarea/textarea.directive.spec.ts +84 -0
  219. package/src/lib/textarea/textarea.directive.ts +71 -0
  220. package/src/lib/textarea/textarea.variants.ts +34 -0
  221. package/src/lib/timeline/index.ts +11 -0
  222. package/src/lib/timeline/timeline.directives.spec.ts +55 -0
  223. package/src/lib/timeline/timeline.directives.ts +85 -0
  224. package/src/lib/toast/index.ts +3 -0
  225. package/src/lib/toast/toast.service.spec.ts +71 -0
  226. package/src/lib/toast/toast.service.ts +60 -0
  227. package/src/lib/toast/toast.variants.ts +38 -0
  228. package/src/lib/toast/toaster.component.spec.ts +38 -0
  229. package/src/lib/toast/toaster.component.ts +81 -0
  230. package/src/lib/toggle/index.ts +2 -0
  231. package/src/lib/toggle/toggle.directive.spec.ts +100 -0
  232. package/src/lib/toggle/toggle.directive.ts +61 -0
  233. package/src/lib/toggle/toggle.variants.ts +25 -0
  234. package/src/lib/tooltip/index.ts +2 -0
  235. package/src/lib/tooltip/tooltip.directive.spec.ts +113 -0
  236. package/src/lib/tooltip/tooltip.directive.ts +130 -0
  237. package/src/lib/tooltip/tooltip.variants.ts +20 -0
  238. package/src/lib/validator/index.ts +5 -0
  239. package/src/lib/validator/validator.directives.spec.ts +47 -0
  240. package/src/lib/validator/validator.directives.ts +50 -0
  241. package/src/styles/sonny-theme.css +45 -0
  242. package/types/sonny-ui-core.d.ts +1443 -13
@@ -0,0 +1,143 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input, model } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import {
4
+ paginationItemVariants,
5
+ type PaginationVariant,
6
+ type PaginationSize,
7
+ } from './pagination.variants';
8
+
9
+ function computePageRange(
10
+ totalPages: number,
11
+ currentPage: number,
12
+ siblingCount: number,
13
+ boundaryCount: number
14
+ ): (number | 'ellipsis')[] {
15
+ const range = (start: number, end: number) =>
16
+ Array.from({ length: end - start + 1 }, (_, i) => start + i);
17
+
18
+ const startPages = range(1, Math.min(boundaryCount, totalPages));
19
+ const endPages = range(Math.max(totalPages - boundaryCount + 1, boundaryCount + 1), totalPages);
20
+
21
+ const siblingsStart = Math.max(
22
+ Math.min(currentPage - siblingCount, totalPages - boundaryCount - siblingCount * 2 - 1),
23
+ boundaryCount + 2
24
+ );
25
+ const siblingsEnd = Math.min(
26
+ Math.max(currentPage + siblingCount, boundaryCount + siblingCount * 2 + 2),
27
+ endPages.length > 0 ? endPages[0] - 2 : totalPages - 1
28
+ );
29
+
30
+ const result: (number | 'ellipsis')[] = [...startPages];
31
+
32
+ if (siblingsStart > boundaryCount + 2) {
33
+ result.push('ellipsis');
34
+ } else if (boundaryCount + 1 < totalPages - boundaryCount) {
35
+ result.push(boundaryCount + 1);
36
+ }
37
+
38
+ result.push(...range(siblingsStart, siblingsEnd));
39
+
40
+ if (siblingsEnd < totalPages - boundaryCount - 1) {
41
+ result.push('ellipsis');
42
+ } else if (totalPages - boundaryCount > boundaryCount) {
43
+ result.push(totalPages - boundaryCount);
44
+ }
45
+
46
+ result.push(...endPages);
47
+
48
+ return [...new Set(result)].sort((a, b) => {
49
+ if (a === 'ellipsis') return 0;
50
+ if (b === 'ellipsis') return 0;
51
+ return a - b;
52
+ });
53
+ }
54
+
55
+ @Component({
56
+ selector: 'sny-pagination',
57
+ changeDetection: ChangeDetectionStrategy.OnPush,
58
+ host: {
59
+ 'role': 'navigation',
60
+ 'aria-label': 'Pagination',
61
+ },
62
+ template: `
63
+ <div class="flex items-center gap-1">
64
+ <button
65
+ [class]="navBtnClass()"
66
+ [disabled]="!hasPrev()"
67
+ [attr.aria-label]="'Go to previous page'"
68
+ (click)="prev()"
69
+ >
70
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
71
+ </button>
72
+
73
+ @for (page of pages(); track $index) {
74
+ @if (page === 'ellipsis') {
75
+ <span class="flex h-9 w-9 items-center justify-center" aria-hidden="true">...</span>
76
+ } @else {
77
+ <button
78
+ [class]="pageClass(page)"
79
+ [attr.aria-label]="'Page ' + page"
80
+ [attr.aria-current]="page === currentPage() ? 'page' : null"
81
+ (click)="goToPage(page)"
82
+ >
83
+ {{ page }}
84
+ </button>
85
+ }
86
+ }
87
+
88
+ <button
89
+ [class]="navBtnClass()"
90
+ [disabled]="!hasNext()"
91
+ [attr.aria-label]="'Go to next page'"
92
+ (click)="next()"
93
+ >
94
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
95
+ </button>
96
+ </div>
97
+ `,
98
+ })
99
+ export class SnyPaginationComponent {
100
+ readonly currentPage = model(1);
101
+ readonly totalPages = input.required<number>();
102
+ readonly siblingCount = input(1);
103
+ readonly boundaryCount = input(1);
104
+ readonly size = input<PaginationSize>('md');
105
+ readonly variant = input<PaginationVariant>('default');
106
+ readonly class = input<string>('');
107
+
108
+ readonly pages = computed(() =>
109
+ computePageRange(this.totalPages(), this.currentPage(), this.siblingCount(), this.boundaryCount())
110
+ );
111
+
112
+ readonly hasPrev = computed(() => this.currentPage() > 1);
113
+ readonly hasNext = computed(() => this.currentPage() < this.totalPages());
114
+
115
+ goToPage(page: number | 'ellipsis'): void {
116
+ if (page === 'ellipsis') return;
117
+ this.currentPage.set(page);
118
+ }
119
+
120
+ prev(): void {
121
+ if (this.hasPrev()) this.currentPage.update((p) => p - 1);
122
+ }
123
+
124
+ next(): void {
125
+ if (this.hasNext()) this.currentPage.update((p) => p + 1);
126
+ }
127
+
128
+ pageClass(page: number): string {
129
+ return cn(
130
+ paginationItemVariants({
131
+ variant: this.variant(),
132
+ size: this.size(),
133
+ active: page === this.currentPage(),
134
+ })
135
+ );
136
+ }
137
+
138
+ navBtnClass(): string {
139
+ return cn(
140
+ paginationItemVariants({ variant: this.variant(), size: this.size(), active: false })
141
+ );
142
+ }
143
+ }
@@ -0,0 +1,31 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const paginationItemVariants = cva(
4
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default: 'bg-background hover:bg-accent hover:text-accent-foreground',
9
+ outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
10
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
11
+ },
12
+ size: {
13
+ sm: 'h-8 w-8 text-xs',
14
+ md: 'h-9 w-9',
15
+ lg: 'h-10 w-10',
16
+ },
17
+ active: {
18
+ true: 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground',
19
+ false: '',
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: 'default',
24
+ size: 'md',
25
+ active: false,
26
+ },
27
+ }
28
+ );
29
+
30
+ export type PaginationVariant = 'default' | 'outline' | 'ghost';
31
+ export type PaginationSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,6 @@
1
+ export {
2
+ SnyPopoverDirective,
3
+ SnyPopoverTriggerDirective,
4
+ SnyPopoverContentDirective,
5
+ SNY_POPOVER,
6
+ } from './popover.directives';
@@ -0,0 +1,147 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import {
4
+ SnyPopoverDirective,
5
+ SnyPopoverTriggerDirective,
6
+ SnyPopoverContentDirective,
7
+ } from './popover.directives';
8
+
9
+ @Component({
10
+ standalone: true,
11
+ imports: [SnyPopoverDirective, SnyPopoverTriggerDirective, SnyPopoverContentDirective],
12
+ template: `
13
+ <div snyPopover [matchWidth]="matchWidth()" [closeOnOutside]="closeOnOutside()" [closeOnEscape]="closeOnEscape()" #pop="snyPopover">
14
+ <button snyPopoverTrigger>Open</button>
15
+ <div snyPopoverContent class="p-4">
16
+ <p>Popover content</p>
17
+ <button class="close-btn" (click)="pop.close()">Close</button>
18
+ </div>
19
+ </div>
20
+ `,
21
+ })
22
+ class TestHost {
23
+ matchWidth = signal(false);
24
+ closeOnOutside = signal(true);
25
+ closeOnEscape = signal(true);
26
+ }
27
+
28
+ describe('SnyPopoverDirective', () => {
29
+ let fixture: ComponentFixture<TestHost>;
30
+ let el: HTMLElement;
31
+
32
+ beforeEach(async () => {
33
+ await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
34
+ fixture = TestBed.createComponent(TestHost);
35
+ fixture.detectChanges();
36
+ el = fixture.nativeElement;
37
+ });
38
+
39
+ function getTrigger(): HTMLButtonElement {
40
+ return el.querySelector('[snypopovertrigger]') as HTMLButtonElement;
41
+ }
42
+
43
+ function getContent(): HTMLElement | null {
44
+ return el.querySelector('[snyPopoverContent], [snypopovercontent]');
45
+ }
46
+
47
+ function isVisible(): boolean {
48
+ const content = getContent();
49
+ return content ? content.style.display !== 'none' : false;
50
+ }
51
+
52
+ it('should render trigger and hidden content', () => {
53
+ expect(getTrigger()).not.toBeNull();
54
+ expect(getContent()).not.toBeNull();
55
+ expect(isVisible()).toBe(false);
56
+ });
57
+
58
+ it('should open on trigger click', () => {
59
+ getTrigger().click();
60
+ fixture.detectChanges();
61
+ expect(isVisible()).toBe(true);
62
+ });
63
+
64
+ it('should close on second trigger click', () => {
65
+ getTrigger().click();
66
+ fixture.detectChanges();
67
+ expect(isVisible()).toBe(true);
68
+
69
+ getTrigger().click();
70
+ fixture.detectChanges();
71
+ expect(isVisible()).toBe(false);
72
+ });
73
+
74
+ it('should set aria-expanded on trigger', () => {
75
+ expect(getTrigger().getAttribute('aria-expanded')).toBe('false');
76
+ getTrigger().click();
77
+ fixture.detectChanges();
78
+ expect(getTrigger().getAttribute('aria-expanded')).toBe('true');
79
+ });
80
+
81
+ it('should have aria-haspopup on trigger', () => {
82
+ expect(getTrigger().getAttribute('aria-haspopup')).toBe('dialog');
83
+ });
84
+
85
+ it('should have role=dialog on content', () => {
86
+ expect(getContent()?.getAttribute('role')).toBe('dialog');
87
+ });
88
+
89
+ it('should close on click outside', () => {
90
+ getTrigger().click();
91
+ fixture.detectChanges();
92
+ expect(isVisible()).toBe(true);
93
+
94
+ document.body.click();
95
+ fixture.detectChanges();
96
+ expect(isVisible()).toBe(false);
97
+ });
98
+
99
+ it('should not close on click outside when closeOnOutside=false', () => {
100
+ fixture.componentInstance.closeOnOutside.set(false);
101
+ fixture.detectChanges();
102
+
103
+ getTrigger().click();
104
+ fixture.detectChanges();
105
+ expect(isVisible()).toBe(true);
106
+
107
+ document.body.click();
108
+ fixture.detectChanges();
109
+ expect(isVisible()).toBe(true);
110
+ });
111
+
112
+ it('should close on escape', () => {
113
+ getTrigger().click();
114
+ fixture.detectChanges();
115
+ expect(isVisible()).toBe(true);
116
+
117
+ const host = el.querySelector('[snyPopover], [snypopover]') as HTMLElement;
118
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
119
+ fixture.detectChanges();
120
+ expect(isVisible()).toBe(false);
121
+ });
122
+
123
+ it('should not close on escape when closeOnEscape=false', () => {
124
+ fixture.componentInstance.closeOnEscape.set(false);
125
+ fixture.detectChanges();
126
+
127
+ getTrigger().click();
128
+ fixture.detectChanges();
129
+ expect(isVisible()).toBe(true);
130
+
131
+ const host = el.querySelector('[snyPopover], [snypopover]') as HTMLElement;
132
+ host.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
133
+ fixture.detectChanges();
134
+ expect(isVisible()).toBe(true);
135
+ });
136
+
137
+ it('should close programmatically via template ref', () => {
138
+ getTrigger().click();
139
+ fixture.detectChanges();
140
+ expect(isVisible()).toBe(true);
141
+
142
+ const closeBtn = el.querySelector('.close-btn') as HTMLButtonElement;
143
+ closeBtn.click();
144
+ fixture.detectChanges();
145
+ expect(isVisible()).toBe(false);
146
+ });
147
+ });
@@ -0,0 +1,151 @@
1
+ import {
2
+ Directive,
3
+ ElementRef,
4
+ InjectionToken,
5
+ OnDestroy,
6
+ computed,
7
+ inject,
8
+ input,
9
+ signal,
10
+ } from '@angular/core';
11
+ import { cn } from '../core/utils/cn';
12
+
13
+ export const SNY_POPOVER = new InjectionToken<SnyPopoverDirective>('SnyPopover');
14
+
15
+ @Directive({
16
+ selector: '[snyPopover]',
17
+ exportAs: 'snyPopover',
18
+ providers: [{ provide: SNY_POPOVER, useExisting: SnyPopoverDirective }],
19
+ host: {
20
+ '[class]': '"relative inline-block"',
21
+ '(document:click)': 'onDocumentClick($event)',
22
+ '(keydown.escape)': 'onEscape()',
23
+ },
24
+ })
25
+ export class SnyPopoverDirective implements OnDestroy {
26
+ private readonly elRef = inject(ElementRef);
27
+
28
+ readonly matchWidth = input(false);
29
+ readonly offset = input(4);
30
+ readonly closeOnOutside = input(true);
31
+ readonly closeOnEscape = input(true);
32
+
33
+ readonly isOpen = signal(false);
34
+ readonly triggerEl = signal<HTMLElement | null>(null);
35
+ readonly panelEl = signal<HTMLElement | null>(null);
36
+
37
+ private scrollHandler: (() => void) | null = null;
38
+ private resizeHandler: (() => void) | null = null;
39
+
40
+ toggle(): void {
41
+ if (this.isOpen()) {
42
+ this.close();
43
+ } else {
44
+ this.open();
45
+ }
46
+ }
47
+
48
+ open(): void {
49
+ this.isOpen.set(true);
50
+ this.addListeners();
51
+ setTimeout(() => this.updatePosition());
52
+ }
53
+
54
+ close(): void {
55
+ this.isOpen.set(false);
56
+ this.removeListeners();
57
+ }
58
+
59
+ updatePosition(): void {
60
+ const trigger = this.triggerEl();
61
+ const panel = this.panelEl();
62
+ if (!trigger || !panel) return;
63
+ const rect = trigger.getBoundingClientRect();
64
+ panel.style.top = `${rect.bottom + this.offset()}px`;
65
+ panel.style.left = `${rect.left}px`;
66
+ if (this.matchWidth()) {
67
+ panel.style.width = `${rect.width}px`;
68
+ }
69
+ }
70
+
71
+ private addListeners(): void {
72
+ this.removeListeners();
73
+ this.scrollHandler = () => {
74
+ requestAnimationFrame(() => this.updatePosition());
75
+ };
76
+ this.resizeHandler = () => {
77
+ requestAnimationFrame(() => this.updatePosition());
78
+ };
79
+ document.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true });
80
+ window.addEventListener('resize', this.resizeHandler, { passive: true });
81
+ }
82
+
83
+ private removeListeners(): void {
84
+ if (this.scrollHandler) {
85
+ document.removeEventListener('scroll', this.scrollHandler, { capture: true } as EventListenerOptions);
86
+ this.scrollHandler = null;
87
+ }
88
+ if (this.resizeHandler) {
89
+ window.removeEventListener('resize', this.resizeHandler);
90
+ this.resizeHandler = null;
91
+ }
92
+ }
93
+
94
+ onDocumentClick(event: MouseEvent): void {
95
+ if (this.closeOnOutside() && this.isOpen() && !this.elRef.nativeElement.contains(event.target)) {
96
+ this.close();
97
+ }
98
+ }
99
+
100
+ onEscape(): void {
101
+ if (this.closeOnEscape() && this.isOpen()) {
102
+ this.close();
103
+ }
104
+ }
105
+
106
+ ngOnDestroy(): void {
107
+ this.removeListeners();
108
+ }
109
+ }
110
+
111
+ @Directive({
112
+ selector: '[snyPopoverTrigger]',
113
+ host: {
114
+ '(click)': 'popover.toggle()',
115
+ '[attr.aria-expanded]': 'popover.isOpen()',
116
+ 'aria-haspopup': 'dialog',
117
+ },
118
+ })
119
+ export class SnyPopoverTriggerDirective {
120
+ protected readonly popover = inject(SNY_POPOVER);
121
+ private readonly elRef = inject(ElementRef);
122
+
123
+ constructor() {
124
+ this.popover.triggerEl.set(this.elRef.nativeElement);
125
+ }
126
+ }
127
+
128
+ @Directive({
129
+ selector: '[snyPopoverContent]',
130
+ host: {
131
+ 'role': 'dialog',
132
+ '[style.display]': 'popover.isOpen() ? "" : "none"',
133
+ '[class]': 'computedClass()',
134
+ },
135
+ })
136
+ export class SnyPopoverContentDirective {
137
+ protected readonly popover = inject(SNY_POPOVER);
138
+ private readonly elRef = inject(ElementRef);
139
+ readonly class = input<string>('');
140
+
141
+ protected readonly computedClass = computed(() =>
142
+ cn(
143
+ 'fixed z-50 rounded-md border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95',
144
+ this.class()
145
+ )
146
+ );
147
+
148
+ constructor() {
149
+ this.popover.panelEl.set(this.elRef.nativeElement);
150
+ }
151
+ }
@@ -0,0 +1,7 @@
1
+ export { SnyProgressComponent } from './progress.component';
2
+ export {
3
+ progressTrackVariants,
4
+ progressBarVariants,
5
+ type ProgressVariant,
6
+ type ProgressSize,
7
+ } from './progress.variants';
@@ -0,0 +1,117 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyProgressComponent } from './progress.component';
4
+ import type { ProgressVariant, ProgressSize } from './progress.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyProgressComponent],
9
+ template: `
10
+ <sny-progress
11
+ [value]="value()"
12
+ [max]="max()"
13
+ [variant]="variant()"
14
+ [size]="size()"
15
+ [indeterminate]="indeterminate()"
16
+ [label]="label()"
17
+ />
18
+ `,
19
+ })
20
+ class TestHostComponent {
21
+ value = signal(50);
22
+ max = signal(100);
23
+ variant = signal<ProgressVariant>('default');
24
+ size = signal<ProgressSize>('md');
25
+ indeterminate = signal(false);
26
+ label = signal('Loading');
27
+ }
28
+
29
+ describe('SnyProgressComponent', () => {
30
+ let fixture: ComponentFixture<TestHostComponent>;
31
+ let host: HTMLElement;
32
+
33
+ beforeEach(async () => {
34
+ await TestBed.configureTestingModule({
35
+ imports: [TestHostComponent],
36
+ }).compileComponents();
37
+ fixture = TestBed.createComponent(TestHostComponent);
38
+ fixture.detectChanges();
39
+ host = fixture.nativeElement.querySelector('sny-progress');
40
+ });
41
+
42
+ it('should render with progressbar role', () => {
43
+ expect(host.getAttribute('role')).toBe('progressbar');
44
+ });
45
+
46
+ it('should set aria-valuenow to current value', () => {
47
+ expect(host.getAttribute('aria-valuenow')).toBe('50');
48
+ });
49
+
50
+ it('should set aria-valuemin to 0', () => {
51
+ expect(host.getAttribute('aria-valuemin')).toBe('0');
52
+ });
53
+
54
+ it('should set aria-valuemax to max value', () => {
55
+ expect(host.getAttribute('aria-valuemax')).toBe('100');
56
+ });
57
+
58
+ it('should set aria-label', () => {
59
+ expect(host.getAttribute('aria-label')).toBe('Loading');
60
+ });
61
+
62
+ it('should calculate percentage correctly', () => {
63
+ const bar = host.querySelector('div > div') as HTMLElement;
64
+ expect(bar.style.width).toBe('50%');
65
+ });
66
+
67
+ it('should update percentage when value changes', () => {
68
+ fixture.componentInstance.value.set(75);
69
+ fixture.detectChanges();
70
+ const bar = host.querySelector('div > div') as HTMLElement;
71
+ expect(bar.style.width).toBe('75%');
72
+ expect(host.getAttribute('aria-valuenow')).toBe('75');
73
+ });
74
+
75
+ it('should cap percentage at 100%', () => {
76
+ fixture.componentInstance.value.set(150);
77
+ fixture.detectChanges();
78
+ const bar = host.querySelector('div > div') as HTMLElement;
79
+ expect(bar.style.width).toBe('100%');
80
+ });
81
+
82
+ it('should omit aria-valuenow when indeterminate', () => {
83
+ fixture.componentInstance.indeterminate.set(true);
84
+ fixture.detectChanges();
85
+ expect(host.getAttribute('aria-valuenow')).toBeNull();
86
+ });
87
+
88
+ it('should apply indeterminate animation class', () => {
89
+ fixture.componentInstance.indeterminate.set(true);
90
+ fixture.detectChanges();
91
+ const bar = host.querySelector('div > div') as HTMLElement;
92
+ expect(bar.className).toContain('animate-progress-indeterminate');
93
+ });
94
+
95
+ it('should apply size variant', () => {
96
+ fixture.componentInstance.size.set('lg');
97
+ fixture.detectChanges();
98
+ const track = host.querySelector('div') as HTMLElement;
99
+ expect(track.className).toContain('h-4');
100
+ });
101
+
102
+ it('should apply success variant', () => {
103
+ fixture.componentInstance.variant.set('success');
104
+ fixture.detectChanges();
105
+ const bar = host.querySelector('div > div') as HTMLElement;
106
+ expect(bar.className).toContain('bg-green-600');
107
+ });
108
+
109
+ it('should work with custom max value', () => {
110
+ fixture.componentInstance.max.set(200);
111
+ fixture.componentInstance.value.set(100);
112
+ fixture.detectChanges();
113
+ const bar = host.querySelector('div > div') as HTMLElement;
114
+ expect(bar.style.width).toBe('50%');
115
+ expect(host.getAttribute('aria-valuemax')).toBe('200');
116
+ });
117
+ });
@@ -0,0 +1,64 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import {
4
+ progressTrackVariants,
5
+ progressBarVariants,
6
+ type ProgressVariant,
7
+ type ProgressSize,
8
+ } from './progress.variants';
9
+
10
+ @Component({
11
+ selector: 'sny-progress',
12
+ changeDetection: ChangeDetectionStrategy.OnPush,
13
+ host: {
14
+ 'role': 'progressbar',
15
+ '[attr.aria-valuenow]': 'indeterminate() ? null : value()',
16
+ '[attr.aria-valuemin]': '0',
17
+ '[attr.aria-valuemax]': 'max()',
18
+ '[attr.aria-label]': 'label()',
19
+ '[class]': '"w-full"',
20
+ },
21
+ template: `
22
+ <div [class]="trackClass()">
23
+ <div
24
+ [class]="barClass()"
25
+ [style.width.%]="indeterminate() ? null : percentage()"
26
+ ></div>
27
+ </div>
28
+ `,
29
+ styles: `
30
+ @keyframes progress-indeterminate {
31
+ 0% { transform: translateX(-100%); }
32
+ 100% { transform: translateX(400%); }
33
+ }
34
+ :host .animate-progress-indeterminate {
35
+ animation: progress-indeterminate 1.5s ease-in-out infinite;
36
+ }
37
+ `,
38
+ })
39
+ export class SnyProgressComponent {
40
+ readonly value = input(0);
41
+ readonly max = input(100);
42
+ readonly variant = input<ProgressVariant>('default');
43
+ readonly size = input<ProgressSize>('md');
44
+ readonly indeterminate = input(false);
45
+ readonly label = input('Progress');
46
+ readonly class = input<string>('');
47
+
48
+ readonly percentage = computed(() =>
49
+ Math.min(100, (this.value() / this.max()) * 100)
50
+ );
51
+
52
+ protected readonly trackClass = computed(() =>
53
+ cn(progressTrackVariants({ size: this.size() }), this.class())
54
+ );
55
+
56
+ protected readonly barClass = computed(() =>
57
+ cn(
58
+ progressBarVariants({
59
+ variant: this.variant(),
60
+ indeterminate: this.indeterminate(),
61
+ })
62
+ )
63
+ );
64
+ }