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

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 +6642 -268
  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 +33 -0
  242. package/types/sonny-ui-core.d.ts +1443 -13
@@ -0,0 +1,136 @@
1
+ import { Component, signal, viewChild } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import {
4
+ SnyTabsDirective,
5
+ SnyTabsListDirective,
6
+ SnyTabsTriggerDirective,
7
+ SnyTabsContentDirective,
8
+ } from './tabs.directives';
9
+
10
+ @Component({
11
+ standalone: true,
12
+ imports: [SnyTabsDirective, SnyTabsListDirective, SnyTabsTriggerDirective, SnyTabsContentDirective],
13
+ template: `
14
+ <div snyTabs [(value)]="activeTab">
15
+ <div snyTabsList>
16
+ <button snyTabsTrigger value="tab1">Tab 1</button>
17
+ <button snyTabsTrigger value="tab2">Tab 2</button>
18
+ </div>
19
+ <div snyTabsContent value="tab1">Content 1</div>
20
+ <div snyTabsContent value="tab2">Content 2</div>
21
+ </div>
22
+ `,
23
+ })
24
+ class TestHostComponent {
25
+ activeTab = signal('tab1');
26
+ }
27
+
28
+ describe('Tabs Directives', () => {
29
+ let fixture: ComponentFixture<TestHostComponent>;
30
+
31
+ beforeEach(async () => {
32
+ await TestBed.configureTestingModule({
33
+ imports: [TestHostComponent],
34
+ }).compileComponents();
35
+
36
+ fixture = TestBed.createComponent(TestHostComponent);
37
+ fixture.detectChanges();
38
+ });
39
+
40
+ it('should show active tab content', () => {
41
+ const contents = fixture.nativeElement.querySelectorAll('[role="tabpanel"]');
42
+ expect(contents[0].style.display).not.toBe('none');
43
+ expect(contents[1].style.display).toBe('none');
44
+ });
45
+
46
+ it('should set aria-selected on active trigger', () => {
47
+ const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
48
+ expect(triggers[0].getAttribute('aria-selected')).toBe('true');
49
+ expect(triggers[1].getAttribute('aria-selected')).toBe('false');
50
+ });
51
+
52
+ it('should switch tabs on click', () => {
53
+ const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
54
+ triggers[1].click();
55
+ fixture.detectChanges();
56
+ const contents = fixture.nativeElement.querySelectorAll('[role="tabpanel"]');
57
+ expect(contents[0].style.display).toBe('none');
58
+ expect(contents[1].style.display).not.toBe('none');
59
+ });
60
+
61
+ it('should have tablist role', () => {
62
+ const list = fixture.nativeElement.querySelector('[role="tablist"]');
63
+ expect(list).toBeTruthy();
64
+ expect(list.className).toContain('bg-muted');
65
+ });
66
+
67
+ it('should implement roving tabindex on triggers', () => {
68
+ const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
69
+ expect(triggers[0].getAttribute('tabindex')).toBe('0');
70
+ expect(triggers[1].getAttribute('tabindex')).toBe('-1');
71
+ });
72
+
73
+ it('should move focus with ArrowRight', () => {
74
+ const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
75
+ (triggers[0] as HTMLElement).focus();
76
+ triggers[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
77
+ fixture.detectChanges();
78
+ const updatedTriggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
79
+ expect(document.activeElement).toBe(updatedTriggers[1]);
80
+ });
81
+
82
+ it('should move focus with ArrowLeft', () => {
83
+ const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
84
+ (triggers[1] as HTMLElement).focus();
85
+ triggers[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
86
+ fixture.detectChanges();
87
+ const updatedTriggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
88
+ expect(document.activeElement).toBe(updatedTriggers[0]);
89
+ });
90
+
91
+ it('should move focus to first with Home', () => {
92
+ const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
93
+ (triggers[1] as HTMLElement).focus();
94
+ triggers[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
95
+ fixture.detectChanges();
96
+ const updatedTriggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
97
+ expect(document.activeElement).toBe(updatedTriggers[0]);
98
+ });
99
+
100
+ it('should move focus to last with End', () => {
101
+ const triggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
102
+ (triggers[0] as HTMLElement).focus();
103
+ triggers[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
104
+ fixture.detectChanges();
105
+ const updatedTriggers = fixture.nativeElement.querySelectorAll('[role="tab"]');
106
+ expect(document.activeElement).toBe(updatedTriggers[1]);
107
+ });
108
+ });
109
+
110
+ @Component({
111
+ standalone: true,
112
+ imports: [SnyTabsDirective, SnyTabsListDirective, SnyTabsTriggerDirective, SnyTabsContentDirective],
113
+ template: `
114
+ <div snyTabs #t="snyTabs" [(value)]="activeTab">
115
+ <div snyTabsList>
116
+ <button snyTabsTrigger value="tab1">Tab 1</button>
117
+ </div>
118
+ <div snyTabsContent value="tab1">Content 1</div>
119
+ </div>
120
+ `,
121
+ })
122
+ class ExportAsHost {
123
+ activeTab = signal('tab1');
124
+ tabs = viewChild<SnyTabsDirective>('t');
125
+ }
126
+
127
+ describe('Tabs exportAs', () => {
128
+ it('should expose snyTabs via template ref', async () => {
129
+ await TestBed.configureTestingModule({ imports: [ExportAsHost] }).compileComponents();
130
+ const fixture = TestBed.createComponent(ExportAsHost);
131
+ fixture.detectChanges();
132
+ const ref = fixture.componentInstance.tabs();
133
+ expect(ref).toBeTruthy();
134
+ expect(ref!.value()).toBe('tab1');
135
+ });
136
+ });
@@ -0,0 +1,126 @@
1
+ import { Directive, ElementRef, computed, inject, input, model, InjectionToken } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ export const SNY_TABS = new InjectionToken<SnyTabsDirective>('SnyTabs');
5
+
6
+ @Directive({
7
+ selector: '[snyTabs]',
8
+ exportAs: 'snyTabs',
9
+ providers: [{ provide: SNY_TABS, useExisting: SnyTabsDirective }],
10
+ host: { '[class]': 'computedClass()' },
11
+ })
12
+ export class SnyTabsDirective {
13
+ readonly value = model<string>('');
14
+ readonly class = input<string>('');
15
+
16
+ protected readonly computedClass = computed(() =>
17
+ cn('', this.class())
18
+ );
19
+
20
+ select(value: string): void {
21
+ this.value.set(value);
22
+ }
23
+ }
24
+
25
+ @Directive({
26
+ selector: '[snyTabsList]',
27
+ host: {
28
+ role: 'tablist',
29
+ '[class]': 'computedClass()',
30
+ '(keydown)': 'onKeydown($event)',
31
+ },
32
+ })
33
+ export class SnyTabsListDirective {
34
+ readonly class = input<string>('');
35
+ private readonly elRef = inject(ElementRef);
36
+
37
+ protected readonly computedClass = computed(() =>
38
+ cn(
39
+ 'inline-flex h-10 items-center justify-center rounded-sm bg-muted p-1 text-muted-foreground',
40
+ this.class()
41
+ )
42
+ );
43
+
44
+ onKeydown(event: KeyboardEvent): void {
45
+ const triggers = Array.from(
46
+ (this.elRef.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('[role="tab"]')
47
+ );
48
+ if (triggers.length === 0) return;
49
+
50
+ const currentIndex = triggers.indexOf(document.activeElement as HTMLElement);
51
+ if (currentIndex === -1) return;
52
+
53
+ let nextIndex: number | null = null;
54
+ switch (event.key) {
55
+ case 'ArrowRight':
56
+ case 'ArrowDown':
57
+ event.preventDefault();
58
+ nextIndex = (currentIndex + 1) % triggers.length;
59
+ break;
60
+ case 'ArrowLeft':
61
+ case 'ArrowUp':
62
+ event.preventDefault();
63
+ nextIndex = (currentIndex - 1 + triggers.length) % triggers.length;
64
+ break;
65
+ case 'Home':
66
+ event.preventDefault();
67
+ nextIndex = 0;
68
+ break;
69
+ case 'End':
70
+ event.preventDefault();
71
+ nextIndex = triggers.length - 1;
72
+ break;
73
+ }
74
+ if (nextIndex !== null) {
75
+ triggers[nextIndex].focus();
76
+ }
77
+ }
78
+ }
79
+
80
+ @Directive({
81
+ selector: '[snyTabsTrigger]',
82
+ host: {
83
+ role: 'tab',
84
+ '[class]': 'computedClass()',
85
+ '[attr.aria-selected]': 'isActive()',
86
+ '[attr.tabindex]': 'isActive() ? 0 : -1',
87
+ '(click)': 'tabs.select(value())',
88
+ },
89
+ })
90
+ export class SnyTabsTriggerDirective {
91
+ readonly value = input.required<string>();
92
+ readonly class = input<string>('');
93
+ readonly tabs = inject(SNY_TABS);
94
+
95
+ readonly isActive = computed(() => this.tabs.value() === this.value());
96
+
97
+ protected readonly computedClass = computed(() =>
98
+ cn(
99
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer',
100
+ this.isActive()
101
+ ? 'bg-background text-foreground shadow-sm'
102
+ : 'hover:bg-background/50',
103
+ this.class()
104
+ )
105
+ );
106
+ }
107
+
108
+ @Directive({
109
+ selector: '[snyTabsContent]',
110
+ host: {
111
+ role: 'tabpanel',
112
+ '[class]': 'computedClass()',
113
+ '[style.display]': 'isActive() ? null : "none"',
114
+ },
115
+ })
116
+ export class SnyTabsContentDirective {
117
+ readonly value = input.required<string>();
118
+ readonly class = input<string>('');
119
+ private readonly tabs = inject(SNY_TABS);
120
+
121
+ readonly isActive = computed(() => this.tabs.value() === this.value());
122
+
123
+ protected readonly computedClass = computed(() =>
124
+ cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', this.class())
125
+ );
126
+ }
@@ -0,0 +1,17 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const tabsListVariants = cva(
4
+ 'inline-flex h-10 items-center justify-center rounded-sm bg-muted p-1 text-muted-foreground',
5
+ {
6
+ variants: {},
7
+ defaultVariants: {},
8
+ }
9
+ );
10
+
11
+ export const tabsTriggerVariants = cva(
12
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
13
+ {
14
+ variants: {},
15
+ defaultVariants: {},
16
+ }
17
+ );
@@ -0,0 +1,2 @@
1
+ export { SnyTagInputComponent } from './tag-input.component';
2
+ export { tagInputContainerVariants, tagVariants, type TagInputSize } from './tag-input.variants';
@@ -0,0 +1,190 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
4
+ import { SnyTagInputComponent } from './tag-input.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyTagInputComponent],
9
+ template: `
10
+ <sny-tag-input
11
+ [(value)]="tags"
12
+ [maxTags]="maxTags()"
13
+ [allowDuplicates]="allowDuplicates()"
14
+ [disabled]="disabled()"
15
+ [validate]="validateFn()"
16
+ (tagAdded)="lastAdded = $event"
17
+ (tagRemoved)="lastRemoved = $event"
18
+ />
19
+ `,
20
+ })
21
+ class TestHost {
22
+ tags = signal<string[]>([]);
23
+ maxTags = signal<number | null>(null);
24
+ allowDuplicates = signal(false);
25
+ disabled = signal(false);
26
+ validateFn = signal<((t: string) => boolean) | null>(null);
27
+ lastAdded: string | null = null;
28
+ lastRemoved: string | null = null;
29
+ }
30
+
31
+ describe('SnyTagInputComponent', () => {
32
+ let fixture: ComponentFixture<TestHost>;
33
+ let el: HTMLElement;
34
+
35
+ beforeEach(async () => {
36
+ await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
37
+ fixture = TestBed.createComponent(TestHost);
38
+ fixture.detectChanges();
39
+ el = fixture.nativeElement;
40
+ });
41
+
42
+ function getInput(): HTMLInputElement {
43
+ return el.querySelector('input') as HTMLInputElement;
44
+ }
45
+
46
+ function getTags(): string[] {
47
+ return Array.from(el.querySelectorAll('span')).map((s) => s.textContent?.trim().replace('×', '').trim() ?? '');
48
+ }
49
+
50
+ function typeAndEnter(text: string): void {
51
+ const input = getInput();
52
+ input.value = text;
53
+ input.dispatchEvent(new Event('input', { bubbles: true }));
54
+ fixture.detectChanges();
55
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
56
+ fixture.detectChanges();
57
+ }
58
+
59
+ it('should render empty with placeholder', () => {
60
+ expect(getInput().placeholder).toBe('Add tag...');
61
+ });
62
+
63
+ it('should add tag on Enter', () => {
64
+ typeAndEnter('Angular');
65
+ expect(fixture.componentInstance.tags()).toContain('Angular');
66
+ expect(fixture.componentInstance.lastAdded).toBe('Angular');
67
+ });
68
+
69
+ it('should add tag on comma', () => {
70
+ const input = getInput();
71
+ input.value = 'React,';
72
+ input.dispatchEvent(new Event('input', { bubbles: true }));
73
+ fixture.detectChanges();
74
+ expect(fixture.componentInstance.tags()).toContain('React');
75
+ });
76
+
77
+ it('should reject duplicates when allowDuplicates=false', () => {
78
+ typeAndEnter('Angular');
79
+ typeAndEnter('Angular');
80
+ expect(fixture.componentInstance.tags().filter((t) => t === 'Angular').length).toBe(1);
81
+ });
82
+
83
+ it('should allow duplicates when allowDuplicates=true', () => {
84
+ fixture.componentInstance.allowDuplicates.set(true);
85
+ fixture.detectChanges();
86
+ typeAndEnter('Angular');
87
+ typeAndEnter('Angular');
88
+ expect(fixture.componentInstance.tags().filter((t) => t === 'Angular').length).toBe(2);
89
+ });
90
+
91
+ it('should respect maxTags', () => {
92
+ fixture.componentInstance.maxTags.set(2);
93
+ fixture.detectChanges();
94
+ typeAndEnter('A');
95
+ typeAndEnter('B');
96
+ // Input should be hidden now (atMax), so we can't type more
97
+ const input = el.querySelector('input');
98
+ expect(input).toBeNull(); // hidden when at max
99
+ expect(fixture.componentInstance.tags().length).toBe(2);
100
+ });
101
+
102
+ it('should validate with custom function', () => {
103
+ fixture.componentInstance.validateFn.set((t: string) => t.length >= 3);
104
+ fixture.detectChanges();
105
+ typeAndEnter('AB');
106
+ expect(fixture.componentInstance.tags().length).toBe(0);
107
+ typeAndEnter('ABC');
108
+ expect(fixture.componentInstance.tags()).toContain('ABC');
109
+ });
110
+
111
+ it('should remove tag on X click', () => {
112
+ typeAndEnter('Angular');
113
+ fixture.detectChanges();
114
+ const removeBtn = el.querySelector('[aria-label="Remove Angular"]') as HTMLButtonElement;
115
+ removeBtn.click();
116
+ fixture.detectChanges();
117
+ expect(fixture.componentInstance.tags().length).toBe(0);
118
+ expect(fixture.componentInstance.lastRemoved).toBe('Angular');
119
+ });
120
+
121
+ it('should remove last tag on Backspace with empty input', () => {
122
+ typeAndEnter('A');
123
+ typeAndEnter('B');
124
+ const input = getInput();
125
+ input.value = '';
126
+ input.dispatchEvent(new Event('input', { bubbles: true }));
127
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
128
+ fixture.detectChanges();
129
+ expect(fixture.componentInstance.tags()).toEqual(['A']);
130
+ });
131
+
132
+ it('should add tag on blur when addOnBlur=true', () => {
133
+ const input = getInput();
134
+ input.value = 'BlurTag';
135
+ input.dispatchEvent(new Event('input', { bubbles: true }));
136
+ input.dispatchEvent(new Event('blur', { bubbles: true }));
137
+ fixture.detectChanges();
138
+ expect(fixture.componentInstance.tags()).toContain('BlurTag');
139
+ });
140
+
141
+ it('should disable when disabled', () => {
142
+ fixture.componentInstance.disabled.set(true);
143
+ fixture.detectChanges();
144
+ expect(getInput().disabled).toBe(true);
145
+ });
146
+
147
+ it('should emit tagAdded and tagRemoved', () => {
148
+ typeAndEnter('Test');
149
+ expect(fixture.componentInstance.lastAdded).toBe('Test');
150
+ const removeBtn = el.querySelector('[aria-label="Remove Test"]') as HTMLButtonElement;
151
+ removeBtn.click();
152
+ fixture.detectChanges();
153
+ expect(fixture.componentInstance.lastRemoved).toBe('Test');
154
+ });
155
+ });
156
+
157
+ @Component({
158
+ standalone: true,
159
+ imports: [ReactiveFormsModule, SnyTagInputComponent],
160
+ template: `<sny-tag-input [formControl]="ctrl" />`,
161
+ })
162
+ class ReactiveHost {
163
+ ctrl = new FormControl<string[]>([]);
164
+ }
165
+
166
+ describe('SnyTagInputComponent — Reactive Forms', () => {
167
+ let fixture: ComponentFixture<ReactiveHost>;
168
+
169
+ beforeEach(async () => {
170
+ await TestBed.configureTestingModule({ imports: [ReactiveHost] }).compileComponents();
171
+ fixture = TestBed.createComponent(ReactiveHost);
172
+ fixture.detectChanges();
173
+ });
174
+
175
+ it('should populate tags from FormControl', () => {
176
+ fixture.componentInstance.ctrl.setValue(['A', 'B', 'C']);
177
+ fixture.detectChanges();
178
+ const tags = fixture.nativeElement.querySelectorAll('span');
179
+ expect(tags.length).toBe(3);
180
+ });
181
+
182
+ it('should update FormControl when tag added', () => {
183
+ const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
184
+ input.value = 'New';
185
+ input.dispatchEvent(new Event('input', { bubbles: true }));
186
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
187
+ fixture.detectChanges();
188
+ expect(fixture.componentInstance.ctrl.value).toContain('New');
189
+ });
190
+ });
@@ -0,0 +1,172 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ ElementRef,
6
+ forwardRef,
7
+ input,
8
+ model,
9
+ output,
10
+ signal,
11
+ viewChild,
12
+ } from '@angular/core';
13
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
14
+ import { cn } from '../core/utils/cn';
15
+ import { tagInputContainerVariants, tagVariants, type TagInputSize } from './tag-input.variants';
16
+
17
+ @Component({
18
+ selector: 'sny-tag-input',
19
+ changeDetection: ChangeDetectionStrategy.OnPush,
20
+ providers: [
21
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyTagInputComponent), multi: true },
22
+ ],
23
+ template: `
24
+ <div [class]="containerClass()" (click)="focusInput()">
25
+ @for (tag of value(); track tag; let i = $index) {
26
+ <span [class]="tagClass()">
27
+ {{ tag }}
28
+ @if (removable() && !isDisabled()) {
29
+ <button
30
+ type="button"
31
+ class="hover:text-destructive transition-colors leading-none"
32
+ (click)="removeTag(i); $event.stopPropagation()"
33
+ [attr.aria-label]="'Remove ' + tag"
34
+ >
35
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
36
+ </button>
37
+ }
38
+ </span>
39
+ }
40
+ @if (!atMax()) {
41
+ <input
42
+ #inputEl
43
+ type="text"
44
+ class="flex-1 min-w-[80px] outline-none bg-transparent"
45
+ [placeholder]="value().length === 0 ? placeholder() : ''"
46
+ [disabled]="isDisabled()"
47
+ [value]="inputValue()"
48
+ (input)="onInput($event)"
49
+ (keydown)="onKeydown($event)"
50
+ (blur)="onBlur()"
51
+ [attr.aria-label]="'Add tag'"
52
+ />
53
+ }
54
+ </div>
55
+ `,
56
+ })
57
+ export class SnyTagInputComponent implements ControlValueAccessor {
58
+ readonly value = model<string[]>([]);
59
+ readonly placeholder = input('Add tag...');
60
+ readonly maxTags = input<number | null>(null);
61
+ readonly allowDuplicates = input(false);
62
+ readonly removable = input(true);
63
+ readonly addOnBlur = input(true);
64
+ readonly separators = input<string[]>(['Enter', ',']);
65
+ readonly validate = input<((tag: string) => boolean) | null>(null);
66
+ readonly disabled = input(false);
67
+ readonly size = input<TagInputSize>('md');
68
+ readonly class = input<string>('');
69
+
70
+ readonly tagAdded = output<string>();
71
+ readonly tagRemoved = output<string>();
72
+
73
+ readonly inputValue = signal('');
74
+ private readonly _disabledByCva = signal(false);
75
+ readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
76
+ readonly atMax = computed(() => this.maxTags() !== null && this.value().length >= this.maxTags()!);
77
+
78
+ readonly containerClass = computed(() =>
79
+ cn(tagInputContainerVariants({ size: this.size() }), this.isDisabled() && 'opacity-50 cursor-not-allowed', this.class())
80
+ );
81
+
82
+ readonly tagClass = computed(() =>
83
+ cn(tagVariants({ size: this.size() }), 'bg-secondary text-secondary-foreground')
84
+ );
85
+
86
+ private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('inputEl');
87
+
88
+ private _onChange: (value: string[]) => void = () => {};
89
+ private _onTouched: () => void = () => {};
90
+
91
+ writeValue(val: string[]): void {
92
+ this.value.set(val ?? []);
93
+ }
94
+
95
+ registerOnChange(fn: (value: string[]) => void): void {
96
+ this._onChange = fn;
97
+ }
98
+
99
+ registerOnTouched(fn: () => void): void {
100
+ this._onTouched = fn;
101
+ }
102
+
103
+ setDisabledState(isDisabled: boolean): void {
104
+ this._disabledByCva.set(isDisabled);
105
+ }
106
+
107
+ focusInput(): void {
108
+ this.inputRef()?.nativeElement.focus();
109
+ }
110
+
111
+ onInput(event: Event): void {
112
+ const val = (event.target as HTMLInputElement).value;
113
+ // Check if separator character was typed (e.g. comma)
114
+ const seps = this.separators().filter((s) => s.length === 1);
115
+ for (const sep of seps) {
116
+ if (val.includes(sep)) {
117
+ const parts = val.split(sep);
118
+ for (const part of parts) {
119
+ this.addTag(part);
120
+ }
121
+ this.inputValue.set('');
122
+ return;
123
+ }
124
+ }
125
+ this.inputValue.set(val);
126
+ }
127
+
128
+ onKeydown(event: KeyboardEvent): void {
129
+ if (this.separators().includes(event.key) && event.key !== ',') {
130
+ event.preventDefault();
131
+ this.addTag(this.inputValue());
132
+ this.inputValue.set('');
133
+ return;
134
+ }
135
+
136
+ if (event.key === 'Backspace' && this.inputValue() === '') {
137
+ const tags = this.value();
138
+ if (tags.length > 0) {
139
+ this.removeTag(tags.length - 1);
140
+ }
141
+ }
142
+ }
143
+
144
+ onBlur(): void {
145
+ if (this.addOnBlur() && this.inputValue().trim()) {
146
+ this.addTag(this.inputValue());
147
+ this.inputValue.set('');
148
+ }
149
+ this._onTouched();
150
+ }
151
+
152
+ addTag(raw: string): void {
153
+ const tag = raw.trim();
154
+ if (!tag) return;
155
+ if (this.atMax()) return;
156
+ if (!this.allowDuplicates() && this.value().includes(tag)) return;
157
+
158
+ const validateFn = this.validate();
159
+ if (validateFn && !validateFn(tag)) return;
160
+
161
+ this.value.update((tags) => [...tags, tag]);
162
+ this._onChange(this.value());
163
+ this.tagAdded.emit(tag);
164
+ }
165
+
166
+ removeTag(index: number): void {
167
+ const removed = this.value()[index];
168
+ this.value.update((tags) => tags.filter((_, i) => i !== index));
169
+ this._onChange(this.value());
170
+ this.tagRemoved.emit(removed);
171
+ }
172
+ }
@@ -0,0 +1,31 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const tagInputContainerVariants = cva(
4
+ 'flex flex-wrap gap-1.5 border border-border rounded-md bg-background px-2 cursor-text transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: 'min-h-[36px] py-1 text-xs',
9
+ md: 'min-h-[40px] py-1.5 text-sm',
10
+ lg: 'min-h-[44px] py-2 text-base',
11
+ },
12
+ },
13
+ defaultVariants: { size: 'md' },
14
+ }
15
+ );
16
+
17
+ export const tagVariants = cva(
18
+ 'inline-flex items-center gap-1 rounded-md font-medium',
19
+ {
20
+ variants: {
21
+ size: {
22
+ sm: 'px-1.5 py-0.5 text-xs',
23
+ md: 'px-2 py-0.5 text-sm',
24
+ lg: 'px-2.5 py-1 text-sm',
25
+ },
26
+ },
27
+ defaultVariants: { size: 'md' },
28
+ }
29
+ );
30
+
31
+ export type TagInputSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,7 @@
1
+ export { SnyTextareaDirective } from './textarea.directive';
2
+ export {
3
+ textareaVariants,
4
+ type TextareaVariant,
5
+ type TextareaSize,
6
+ type TextareaResize,
7
+ } from './textarea.variants';