@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,81 @@
1
+ import { Directive, ElementRef, computed, inject, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ export type DockPosition = 'bottom' | 'top';
5
+
6
+ @Directive({
7
+ selector: '[snyDock]',
8
+ host: {
9
+ 'role': 'toolbar',
10
+ 'aria-label': 'Dock',
11
+ '[class]': 'computedClass()',
12
+ '(keydown)': 'onKeydown($event)',
13
+ },
14
+ })
15
+ export class SnyDockDirective {
16
+ readonly position = input<DockPosition>('bottom');
17
+ readonly class = input<string>('');
18
+
19
+ private readonly elRef = inject(ElementRef);
20
+
21
+ protected readonly computedClass = computed(() =>
22
+ cn(
23
+ 'fixed left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 rounded-full border bg-background/80 backdrop-blur-sm px-3 py-2 shadow-lg',
24
+ this.position() === 'bottom' ? 'bottom-4' : 'top-4',
25
+ this.class()
26
+ )
27
+ );
28
+
29
+ onKeydown(event: KeyboardEvent): void {
30
+ const items = Array.from(
31
+ (this.elRef.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('[snyDockItem]')
32
+ );
33
+ if (items.length === 0) return;
34
+
35
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement);
36
+ if (currentIndex === -1) return;
37
+
38
+ let nextIndex: number | null = null;
39
+ switch (event.key) {
40
+ case 'ArrowRight':
41
+ event.preventDefault();
42
+ nextIndex = (currentIndex + 1) % items.length;
43
+ break;
44
+ case 'ArrowLeft':
45
+ event.preventDefault();
46
+ nextIndex = (currentIndex - 1 + items.length) % items.length;
47
+ break;
48
+ case 'Home':
49
+ event.preventDefault();
50
+ nextIndex = 0;
51
+ break;
52
+ case 'End':
53
+ event.preventDefault();
54
+ nextIndex = items.length - 1;
55
+ break;
56
+ }
57
+ if (nextIndex !== null) {
58
+ items[nextIndex].focus();
59
+ }
60
+ }
61
+ }
62
+
63
+ @Directive({
64
+ selector: '[snyDockItem]',
65
+ host: {
66
+ '[class]': 'computedClass()',
67
+ '[attr.tabindex]': 'active() ? 0 : -1',
68
+ },
69
+ })
70
+ export class SnyDockItemDirective {
71
+ readonly active = input(false);
72
+ readonly class = input<string>('');
73
+
74
+ protected readonly computedClass = computed(() =>
75
+ cn(
76
+ 'inline-flex items-center justify-center rounded-full p-2 transition-all hover:scale-110',
77
+ this.active() && 'bg-primary text-primary-foreground',
78
+ this.class()
79
+ )
80
+ );
81
+ }
@@ -0,0 +1 @@
1
+ export { SnyDockDirective, SnyDockItemDirective, type DockPosition } from './dock.directives';
@@ -0,0 +1,62 @@
1
+ import { Component, viewChild } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyDrawerLayoutDirective, SnyDrawerContentDirective, SnyDrawerSideDirective } from './drawer.directives';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyDrawerLayoutDirective, SnyDrawerContentDirective, SnyDrawerSideDirective],
8
+ template: `
9
+ <div snyDrawerLayout #drawer="snyDrawerLayout">
10
+ <div snyDrawerSide>Sidebar</div>
11
+ <div snyDrawerContent>Main</div>
12
+ </div>
13
+ `,
14
+ })
15
+ class TestHostComponent {
16
+ drawer = viewChild(SnyDrawerLayoutDirective);
17
+ }
18
+
19
+ describe('SnyDrawerLayoutDirective', () => {
20
+ let fixture: ComponentFixture<TestHostComponent>;
21
+
22
+ beforeEach(async () => {
23
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
24
+ fixture = TestBed.createComponent(TestHostComponent);
25
+ fixture.detectChanges();
26
+ });
27
+
28
+ it('should be closed by default', () => {
29
+ const side = fixture.nativeElement.querySelector('[snyDrawerSide]');
30
+ expect(side.className).toContain('-translate-x-full');
31
+ });
32
+
33
+ it('should open on toggle', () => {
34
+ fixture.componentInstance.drawer()!.toggle();
35
+ fixture.detectChanges();
36
+ const side = fixture.nativeElement.querySelector('[snyDrawerSide]');
37
+ expect(side.className).toContain('translate-x-0');
38
+ });
39
+
40
+ it('should close after open', () => {
41
+ const d = fixture.componentInstance.drawer()!;
42
+ d.open();
43
+ fixture.detectChanges();
44
+ d.close();
45
+ fixture.detectChanges();
46
+ const side = fixture.nativeElement.querySelector('[snyDrawerSide]');
47
+ expect(side.className).toContain('-translate-x-full');
48
+ });
49
+
50
+ it('should have navigation role on drawer side', () => {
51
+ const side = fixture.nativeElement.querySelector('[snyDrawerSide]');
52
+ expect(side.getAttribute('role')).toBe('navigation');
53
+ });
54
+
55
+ it('should set aria-modal on side when overlay and open', () => {
56
+ const side = fixture.nativeElement.querySelector('[snyDrawerSide]');
57
+ expect(side.getAttribute('aria-modal')).toBeNull();
58
+ fixture.componentInstance.drawer()!.open();
59
+ fixture.detectChanges();
60
+ expect(side.getAttribute('aria-modal')).toBe('true');
61
+ });
62
+ });
@@ -0,0 +1,80 @@
1
+ import { ChangeDetectionStrategy, Component, Directive, InjectionToken, computed, inject, input, signal } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ export const SNY_DRAWER = new InjectionToken<SnyDrawerLayoutComponent>('SnyDrawer');
5
+
6
+ @Component({
7
+ selector: '[snyDrawerLayout]',
8
+ changeDetection: ChangeDetectionStrategy.OnPush,
9
+ exportAs: 'snyDrawerLayout',
10
+ providers: [{ provide: SNY_DRAWER, useExisting: SnyDrawerLayoutComponent }],
11
+ host: {
12
+ '[class]': 'computedClass()',
13
+ },
14
+ template: `
15
+ <ng-content />
16
+ @if (isOpen() && overlay()) {
17
+ <div
18
+ class="fixed inset-0 z-30 bg-black/50 transition-opacity"
19
+ (click)="close()"
20
+ ></div>
21
+ }
22
+ `,
23
+ })
24
+ export class SnyDrawerLayoutComponent {
25
+ readonly class = input<string>('');
26
+ readonly overlay = input(true);
27
+ readonly isOpen = signal(false);
28
+
29
+ protected readonly computedClass = computed(() =>
30
+ cn('relative flex h-full w-full overflow-hidden', this.class())
31
+ );
32
+
33
+ toggle(): void { this.isOpen.update((v) => !v); }
34
+ open(): void { this.isOpen.set(true); }
35
+ close(): void { this.isOpen.set(false); }
36
+ }
37
+
38
+ /** @deprecated Use SnyDrawerLayoutComponent instead */
39
+ export const SnyDrawerLayoutDirective = SnyDrawerLayoutComponent;
40
+
41
+ @Directive({
42
+ selector: '[snyDrawerContent]',
43
+ host: {
44
+ '[class]': 'computedClass()',
45
+ },
46
+ })
47
+ export class SnyDrawerContentDirective {
48
+ readonly class = input<string>('');
49
+ protected readonly computedClass = computed(() =>
50
+ cn('flex-1 overflow-auto', this.class())
51
+ );
52
+ }
53
+
54
+ export type DrawerSide = 'left' | 'right';
55
+
56
+ @Directive({
57
+ selector: '[snyDrawerSide]',
58
+ host: {
59
+ 'role': 'navigation',
60
+ 'aria-label': 'Sidebar navigation',
61
+ '[attr.aria-modal]': 'drawer.overlay() && drawer.isOpen() || null',
62
+ '[class]': 'computedClass()',
63
+ },
64
+ })
65
+ export class SnyDrawerSideDirective {
66
+ protected readonly drawer = inject(SNY_DRAWER);
67
+ readonly side = input<DrawerSide>('left');
68
+ readonly class = input<string>('');
69
+
70
+ protected readonly computedClass = computed(() => {
71
+ const isOpen = this.drawer.isOpen();
72
+ const s = this.side();
73
+ const base = 'fixed inset-y-0 z-50 w-64 bg-background border-r border-border transition-transform duration-300 ease-in-out overflow-y-auto';
74
+ const sideClass = s === 'left' ? 'left-0' : 'right-0 border-l border-r-0';
75
+ const transformClass = isOpen
76
+ ? 'translate-x-0'
77
+ : s === 'left' ? '-translate-x-full' : 'translate-x-full';
78
+ return cn(base, sideClass, transformClass, this.class());
79
+ });
80
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ SnyDrawerLayoutComponent,
3
+ SnyDrawerLayoutDirective,
4
+ SnyDrawerContentDirective,
5
+ SnyDrawerSideDirective,
6
+ SNY_DRAWER,
7
+ type DrawerSide,
8
+ } from './drawer.directives';
@@ -0,0 +1,106 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import {
4
+ SnyMenuContentDirective,
5
+ SnyMenuItemDirective,
6
+ SnyMenuSeparatorDirective,
7
+ SnyMenuLabelDirective,
8
+ } from './dropdown.directives';
9
+ import type { DropdownItemVariant } from './dropdown.variants';
10
+
11
+ @Component({
12
+ standalone: true,
13
+ imports: [
14
+ SnyMenuContentDirective,
15
+ SnyMenuItemDirective,
16
+ SnyMenuSeparatorDirective,
17
+ SnyMenuLabelDirective,
18
+ ],
19
+ template: `
20
+ <div snyMenuContent>
21
+ <div snyMenuLabel>Actions</div>
22
+ <div snyMenuItem [variant]="variant()">Edit</div>
23
+ <div snyMenuSeparator></div>
24
+ <div snyMenuItem variant="destructive">Delete</div>
25
+ </div>
26
+ `,
27
+ })
28
+ class TestHostComponent {
29
+ variant = signal<DropdownItemVariant>('default');
30
+ }
31
+
32
+ describe('SnyMenuContentDirective', () => {
33
+ let fixture: ComponentFixture<TestHostComponent>;
34
+
35
+ beforeEach(async () => {
36
+ await TestBed.configureTestingModule({
37
+ imports: [TestHostComponent],
38
+ }).compileComponents();
39
+ fixture = TestBed.createComponent(TestHostComponent);
40
+ fixture.detectChanges();
41
+ });
42
+
43
+ it('should apply content classes', () => {
44
+ const content = fixture.nativeElement.querySelector('[snyMenuContent]');
45
+ expect(content.className).toContain('rounded-md');
46
+ expect(content.className).toContain('bg-popover');
47
+ });
48
+ });
49
+
50
+ describe('SnyMenuItemDirective', () => {
51
+ let fixture: ComponentFixture<TestHostComponent>;
52
+
53
+ beforeEach(async () => {
54
+ await TestBed.configureTestingModule({
55
+ imports: [TestHostComponent],
56
+ }).compileComponents();
57
+ fixture = TestBed.createComponent(TestHostComponent);
58
+ fixture.detectChanges();
59
+ });
60
+
61
+ it('should apply default item classes', () => {
62
+ const items = fixture.nativeElement.querySelectorAll('[snyMenuItem]');
63
+ expect(items[0].className).toContain('text-sm');
64
+ expect(items[0].className).toContain('rounded-sm');
65
+ });
66
+
67
+ it('should apply destructive variant', () => {
68
+ const items = fixture.nativeElement.querySelectorAll('[snyMenuItem]');
69
+ expect(items[1].className).toContain('text-destructive');
70
+ });
71
+ });
72
+
73
+ describe('SnyMenuSeparatorDirective', () => {
74
+ let fixture: ComponentFixture<TestHostComponent>;
75
+
76
+ beforeEach(async () => {
77
+ await TestBed.configureTestingModule({
78
+ imports: [TestHostComponent],
79
+ }).compileComponents();
80
+ fixture = TestBed.createComponent(TestHostComponent);
81
+ fixture.detectChanges();
82
+ });
83
+
84
+ it('should apply separator classes and role', () => {
85
+ const separator = fixture.nativeElement.querySelector('[snyMenuSeparator]');
86
+ expect(separator.getAttribute('role')).toBe('separator');
87
+ expect(separator.className).toContain('bg-muted');
88
+ });
89
+ });
90
+
91
+ describe('SnyMenuLabelDirective', () => {
92
+ let fixture: ComponentFixture<TestHostComponent>;
93
+
94
+ beforeEach(async () => {
95
+ await TestBed.configureTestingModule({
96
+ imports: [TestHostComponent],
97
+ }).compileComponents();
98
+ fixture = TestBed.createComponent(TestHostComponent);
99
+ fixture.detectChanges();
100
+ });
101
+
102
+ it('should apply label classes', () => {
103
+ const label = fixture.nativeElement.querySelector('[snyMenuLabel]');
104
+ expect(label.className).toContain('font-semibold');
105
+ });
106
+ });
@@ -0,0 +1,136 @@
1
+ import { Directive, ElementRef, InjectionToken, computed, inject, input, signal } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import {
4
+ dropdownContentVariants,
5
+ dropdownItemVariants,
6
+ type DropdownItemVariant,
7
+ } from './dropdown.variants';
8
+
9
+ export const SNY_DROPDOWN = new InjectionToken<SnyDropdownDirective>('SnyDropdown');
10
+
11
+ @Directive({
12
+ selector: '[snyDropdown]',
13
+ exportAs: 'snyDropdown',
14
+ providers: [{ provide: SNY_DROPDOWN, useExisting: SnyDropdownDirective }],
15
+ host: {
16
+ '[class]': '"relative inline-block"',
17
+ '(document:click)': 'onDocumentClick($event)',
18
+ '(keydown.escape)': 'onEscape()',
19
+ },
20
+ })
21
+ export class SnyDropdownDirective {
22
+ private readonly elementRef = inject(ElementRef);
23
+ readonly isOpen = signal(false);
24
+
25
+ toggle(): void { this.isOpen.update((v) => !v); }
26
+ open(): void { this.isOpen.set(true); }
27
+ close(): void { this.isOpen.set(false); }
28
+
29
+ onDocumentClick(event: MouseEvent): void {
30
+ if (!this.elementRef.nativeElement.contains(event.target)) {
31
+ this.close();
32
+ }
33
+ }
34
+
35
+ onEscape(): void {
36
+ this.close();
37
+ }
38
+ }
39
+
40
+ @Directive({
41
+ selector: '[snyDropdownTrigger]',
42
+ host: {
43
+ '(click)': 'dropdown.toggle()',
44
+ '[attr.aria-expanded]': 'dropdown.isOpen()',
45
+ '[attr.aria-haspopup]': '"menu"',
46
+ },
47
+ })
48
+ export class SnyDropdownTriggerDirective {
49
+ readonly dropdown = inject(SNY_DROPDOWN);
50
+ }
51
+
52
+ @Directive({
53
+ selector: '[snyDropdownContent]',
54
+ host: {
55
+ 'role': 'menu',
56
+ '[class]': 'computedClass()',
57
+ '[style.display]': 'dropdown.isOpen() ? "" : "none"',
58
+ },
59
+ })
60
+ export class SnyDropdownContentDirective {
61
+ readonly dropdown = inject(SNY_DROPDOWN);
62
+ readonly class = input<string>('');
63
+
64
+ protected readonly computedClass = computed(() =>
65
+ cn(
66
+ dropdownContentVariants(),
67
+ 'absolute mt-1 left-0 animate-in fade-in-0 zoom-in-95',
68
+ this.class()
69
+ )
70
+ );
71
+ }
72
+
73
+ @Directive({
74
+ selector: '[snyMenuContent]',
75
+ host: {
76
+ '[class]': 'computedClass()',
77
+ },
78
+ })
79
+ export class SnyMenuContentDirective {
80
+ readonly class = input<string>('');
81
+
82
+ protected readonly computedClass = computed(() =>
83
+ cn(dropdownContentVariants(), this.class())
84
+ );
85
+ }
86
+
87
+ @Directive({
88
+ selector: '[snyMenuItem]',
89
+ host: {
90
+ 'role': 'menuitem',
91
+ '[class]': 'computedClass()',
92
+ '(click)': 'onClick()',
93
+ },
94
+ })
95
+ export class SnyMenuItemDirective {
96
+ private readonly dropdown = inject(SNY_DROPDOWN, { optional: true });
97
+ readonly variant = input<DropdownItemVariant>('default');
98
+ readonly class = input<string>('');
99
+
100
+ protected readonly computedClass = computed(() =>
101
+ cn(dropdownItemVariants({ variant: this.variant() }), 'cursor-pointer hover:bg-accent hover:text-accent-foreground', this.class())
102
+ );
103
+
104
+ onClick(): void {
105
+ this.dropdown?.close();
106
+ }
107
+ }
108
+
109
+ @Directive({
110
+ selector: '[snyMenuSeparator]',
111
+ host: {
112
+ 'role': 'separator',
113
+ '[class]': 'computedClass()',
114
+ },
115
+ })
116
+ export class SnyMenuSeparatorDirective {
117
+ readonly class = input<string>('');
118
+
119
+ protected readonly computedClass = computed(() =>
120
+ cn('-mx-1 my-1 h-px bg-muted', this.class())
121
+ );
122
+ }
123
+
124
+ @Directive({
125
+ selector: '[snyMenuLabel]',
126
+ host: {
127
+ '[class]': 'computedClass()',
128
+ },
129
+ })
130
+ export class SnyMenuLabelDirective {
131
+ readonly class = input<string>('');
132
+
133
+ protected readonly computedClass = computed(() =>
134
+ cn('px-2 py-1.5 text-sm font-semibold', this.class())
135
+ );
136
+ }
@@ -0,0 +1,27 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const dropdownContentVariants = cva(
4
+ 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
5
+ {
6
+ variants: {},
7
+ defaultVariants: {},
8
+ }
9
+ );
10
+
11
+ export const dropdownItemVariants = cva(
12
+ 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[active]:bg-accent data-[active]:text-accent-foreground',
13
+ {
14
+ variants: {
15
+ variant: {
16
+ default: '',
17
+ destructive:
18
+ 'text-destructive data-[active]:bg-destructive/10 data-[active]:text-destructive',
19
+ },
20
+ },
21
+ defaultVariants: {
22
+ variant: 'default',
23
+ },
24
+ }
25
+ );
26
+
27
+ export type DropdownItemVariant = 'default' | 'destructive';
@@ -0,0 +1,15 @@
1
+ export {
2
+ SnyDropdownDirective,
3
+ SnyDropdownTriggerDirective,
4
+ SnyDropdownContentDirective,
5
+ SnyMenuContentDirective,
6
+ SnyMenuItemDirective,
7
+ SnyMenuSeparatorDirective,
8
+ SnyMenuLabelDirective,
9
+ SNY_DROPDOWN,
10
+ } from './dropdown.directives';
11
+ export {
12
+ dropdownContentVariants,
13
+ dropdownItemVariants,
14
+ type DropdownItemVariant,
15
+ } from './dropdown.variants';
@@ -0,0 +1,60 @@
1
+ import { Component, viewChild } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyFabDirective, SnyFabTriggerDirective, SnyFabActionDirective } from './fab.directives';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnyFabDirective, SnyFabTriggerDirective, SnyFabActionDirective],
8
+ template: `
9
+ <div snyFab>
10
+ <button snyFabAction ariaLabel="Edit item">Action 1</button>
11
+ <button snyFabAction>Action 2</button>
12
+ <button snyFabTrigger>+</button>
13
+ </div>
14
+ `,
15
+ })
16
+ class TestHostComponent {
17
+ fab = viewChild(SnyFabDirective);
18
+ }
19
+
20
+ describe('SnyFabDirective', () => {
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 be closed by default', () => {
30
+ const actions = fixture.nativeElement.querySelectorAll('[snyFabAction]');
31
+ actions.forEach((a: HTMLElement) => expect(a.className).toContain('scale-0'));
32
+ });
33
+
34
+ it('should open on trigger click', () => {
35
+ const trigger = fixture.nativeElement.querySelector('[snyFabTrigger]');
36
+ trigger.click();
37
+ fixture.detectChanges();
38
+ const actions = fixture.nativeElement.querySelectorAll('[snyFabAction]');
39
+ actions.forEach((a: HTMLElement) => expect(a.className).toContain('scale-100'));
40
+ });
41
+
42
+ it('should have aria-expanded on trigger', () => {
43
+ const trigger = fixture.nativeElement.querySelector('[snyFabTrigger]');
44
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
45
+ trigger.click();
46
+ fixture.detectChanges();
47
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
48
+ });
49
+
50
+ it('should have menuitem role on actions', () => {
51
+ const actions = fixture.nativeElement.querySelectorAll('[snyFabAction]');
52
+ actions.forEach((a: HTMLElement) => expect(a.getAttribute('role')).toBe('menuitem'));
53
+ });
54
+
55
+ it('should set aria-label on action when ariaLabel input is provided', () => {
56
+ const actions = fixture.nativeElement.querySelectorAll('[snyFabAction]');
57
+ expect(actions[0].getAttribute('aria-label')).toBe('Edit item');
58
+ expect(actions[1].getAttribute('aria-label')).toBeNull();
59
+ });
60
+ });
@@ -0,0 +1,77 @@
1
+ import { Directive, InjectionToken, computed, inject, input, signal } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ export const SNY_FAB = new InjectionToken<SnyFabDirective>('SnyFab');
5
+ export type FabPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
6
+ export type FabDirection = 'up' | 'down' | 'left' | 'right';
7
+
8
+ const positionMap: Record<FabPosition, string> = {
9
+ 'bottom-right': 'fixed bottom-6 right-6',
10
+ 'bottom-left': 'fixed bottom-6 left-6',
11
+ 'top-right': 'fixed top-6 right-6',
12
+ 'top-left': 'fixed top-6 left-6',
13
+ };
14
+
15
+ @Directive({
16
+ selector: '[snyFab]',
17
+ exportAs: 'snyFab',
18
+ providers: [{ provide: SNY_FAB, useExisting: SnyFabDirective }],
19
+ host: { '[class]': 'computedClass()' },
20
+ })
21
+ export class SnyFabDirective {
22
+ readonly position = input<FabPosition>('bottom-right');
23
+ readonly direction = input<FabDirection>('up');
24
+ readonly class = input<string>('');
25
+ readonly isOpen = signal(false);
26
+
27
+ toggle(): void { this.isOpen.update((v) => !v); }
28
+ open(): void { this.isOpen.set(true); }
29
+ close(): void { this.isOpen.set(false); }
30
+
31
+ protected readonly computedClass = computed(() =>
32
+ cn('z-50 flex flex-col items-center gap-2', positionMap[this.position()], this.class())
33
+ );
34
+ }
35
+
36
+ @Directive({
37
+ selector: '[snyFabTrigger]',
38
+ host: {
39
+ '(click)': 'fab.toggle()',
40
+ '[attr.aria-expanded]': 'fab.isOpen()',
41
+ '[attr.aria-haspopup]': '"menu"',
42
+ 'aria-label': 'Quick actions',
43
+ '[class]': 'computedClass()',
44
+ },
45
+ })
46
+ export class SnyFabTriggerDirective {
47
+ readonly fab = inject(SNY_FAB);
48
+ readonly class = input<string>('');
49
+ protected readonly computedClass = computed(() =>
50
+ cn(
51
+ 'inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg h-14 w-14 hover:bg-primary/90 transition-transform',
52
+ this.fab.isOpen() && 'rotate-45',
53
+ this.class()
54
+ )
55
+ );
56
+ }
57
+
58
+ @Directive({
59
+ selector: '[snyFabAction]',
60
+ host: {
61
+ 'role': 'menuitem',
62
+ '[attr.aria-label]': 'ariaLabel() || null',
63
+ '[class]': 'computedClass()',
64
+ },
65
+ })
66
+ export class SnyFabActionDirective {
67
+ readonly fab = inject(SNY_FAB);
68
+ readonly ariaLabel = input<string>('');
69
+ readonly class = input<string>('');
70
+ protected readonly computedClass = computed(() =>
71
+ cn(
72
+ 'inline-flex items-center justify-center rounded-full bg-secondary text-secondary-foreground shadow-md h-10 w-10 transition-all',
73
+ this.fab.isOpen() ? 'scale-100 opacity-100' : 'scale-0 opacity-0',
74
+ this.class()
75
+ )
76
+ );
77
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ SnyFabDirective,
3
+ SnyFabTriggerDirective,
4
+ SnyFabActionDirective,
5
+ SNY_FAB,
6
+ type FabPosition,
7
+ type FabDirection,
8
+ } from './fab.directives';