@sonny-ui/core 0.1.0-alpha.1 → 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +101 -32
  2. package/fesm2022/sonny-ui-core.mjs +3031 -42
  3. package/fesm2022/sonny-ui-core.mjs.map +1 -1
  4. package/package.json +8 -5
  5. package/schematics/ng-add/schema.json +1 -1
  6. package/schematics/ng-generate/component/index.js +1 -1
  7. package/schematics/ng-generate/component/schema.json +1 -1
  8. package/src/lib/accordion/accordion.directives.spec.ts +173 -0
  9. package/src/lib/accordion/accordion.directives.ts +147 -0
  10. package/src/lib/accordion/index.ts +8 -0
  11. package/src/lib/alert/alert.directives.spec.ts +154 -0
  12. package/src/lib/alert/alert.directives.ts +70 -0
  13. package/src/lib/alert/alert.variants.ts +25 -0
  14. package/src/lib/alert/index.ts +6 -0
  15. package/src/lib/avatar/avatar.component.spec.ts +75 -0
  16. package/src/lib/avatar/avatar.component.ts +44 -0
  17. package/src/lib/avatar/avatar.variants.ts +26 -0
  18. package/src/lib/avatar/index.ts +2 -0
  19. package/src/lib/badge/badge.directive.spec.ts +74 -0
  20. package/src/lib/badge/badge.directive.ts +18 -0
  21. package/src/lib/badge/badge.variants.ts +29 -0
  22. package/src/lib/badge/index.ts +2 -0
  23. package/src/lib/breadcrumb/breadcrumb.directives.spec.ts +80 -0
  24. package/src/lib/breadcrumb/breadcrumb.directives.ts +84 -0
  25. package/src/lib/breadcrumb/index.ts +8 -0
  26. package/src/lib/button/button.directive.spec.ts +92 -0
  27. package/src/lib/button/button.directive.ts +29 -0
  28. package/src/lib/button/button.variants.ts +30 -0
  29. package/src/lib/button/index.ts +2 -0
  30. package/src/lib/button-group/button-group.directive.spec.ts +46 -0
  31. package/src/lib/button-group/button-group.directive.ts +20 -0
  32. package/src/lib/button-group/button-group.variants.ts +18 -0
  33. package/src/lib/button-group/index.ts +2 -0
  34. package/src/lib/calendar/calendar.component.spec.ts +105 -0
  35. package/src/lib/calendar/calendar.component.ts +231 -0
  36. package/src/lib/calendar/index.ts +1 -0
  37. package/src/lib/card/card.directives.spec.ts +104 -0
  38. package/src/lib/card/card.directives.ts +78 -0
  39. package/src/lib/card/card.variants.ts +28 -0
  40. package/src/lib/card/index.ts +9 -0
  41. package/src/lib/carousel/carousel.directives.spec.ts +85 -0
  42. package/src/lib/carousel/carousel.directives.ts +164 -0
  43. package/src/lib/carousel/index.ts +8 -0
  44. package/src/lib/chat-bubble/chat-bubble.directives.spec.ts +52 -0
  45. package/src/lib/chat-bubble/chat-bubble.directives.ts +102 -0
  46. package/src/lib/chat-bubble/index.ts +11 -0
  47. package/src/lib/checkbox/checkbox.directive.spec.ts +57 -0
  48. package/src/lib/checkbox/checkbox.directive.ts +17 -0
  49. package/src/lib/checkbox/checkbox.variants.ts +19 -0
  50. package/src/lib/checkbox/index.ts +2 -0
  51. package/src/lib/combobox/combobox.component.spec.ts +151 -0
  52. package/src/lib/combobox/combobox.component.ts +279 -0
  53. package/src/lib/combobox/combobox.variants.ts +19 -0
  54. package/src/lib/combobox/index.ts +2 -0
  55. package/src/lib/diff/diff.component.spec.ts +47 -0
  56. package/src/lib/diff/diff.component.ts +83 -0
  57. package/src/lib/diff/index.ts +1 -0
  58. package/src/lib/divider/divider.component.spec.ts +48 -0
  59. package/src/lib/divider/divider.component.ts +52 -0
  60. package/src/lib/divider/divider.variants.ts +22 -0
  61. package/src/lib/divider/index.ts +2 -0
  62. package/src/lib/dock/dock.directives.spec.ts +85 -0
  63. package/src/lib/dock/dock.directives.ts +83 -0
  64. package/src/lib/dock/index.ts +1 -0
  65. package/src/lib/drawer/drawer.directives.spec.ts +62 -0
  66. package/src/lib/drawer/drawer.directives.ts +83 -0
  67. package/src/lib/drawer/index.ts +8 -0
  68. package/src/lib/dropdown/dropdown.directives.spec.ts +106 -0
  69. package/src/lib/dropdown/dropdown.directives.ts +143 -0
  70. package/src/lib/dropdown/dropdown.variants.ts +27 -0
  71. package/src/lib/dropdown/index.ts +15 -0
  72. package/src/lib/fab/fab.directives.spec.ts +60 -0
  73. package/src/lib/fab/fab.directives.ts +80 -0
  74. package/src/lib/fab/index.ts +8 -0
  75. package/src/lib/fieldset/fieldset.directives.spec.ts +74 -0
  76. package/src/lib/fieldset/fieldset.directives.ts +52 -0
  77. package/src/lib/fieldset/fieldset.variants.ts +15 -0
  78. package/src/lib/fieldset/index.ts +6 -0
  79. package/src/lib/file-input/file-input.component.spec.ts +114 -0
  80. package/src/lib/file-input/file-input.component.ts +168 -0
  81. package/src/lib/file-input/file-input.variants.ts +25 -0
  82. package/src/lib/file-input/index.ts +6 -0
  83. package/src/lib/indicator/index.ts +6 -0
  84. package/src/lib/indicator/indicator.directives.spec.ts +64 -0
  85. package/src/lib/indicator/indicator.directives.ts +61 -0
  86. package/src/lib/input/index.ts +3 -0
  87. package/src/lib/input/input.directive.spec.ts +103 -0
  88. package/src/lib/input/input.directive.ts +26 -0
  89. package/src/lib/input/input.variants.ts +42 -0
  90. package/src/lib/input/label.directive.ts +17 -0
  91. package/src/lib/kbd/index.ts +2 -0
  92. package/src/lib/kbd/kbd.directive.spec.ts +42 -0
  93. package/src/lib/kbd/kbd.directive.ts +19 -0
  94. package/src/lib/kbd/kbd.variants.ts +19 -0
  95. package/src/lib/link/index.ts +2 -0
  96. package/src/lib/link/link.directive.spec.ts +41 -0
  97. package/src/lib/link/link.directive.ts +19 -0
  98. package/src/lib/link/link.variants.ts +20 -0
  99. package/src/lib/list/index.ts +8 -0
  100. package/src/lib/list/list.directives.spec.ts +65 -0
  101. package/src/lib/list/list.directives.ts +86 -0
  102. package/src/lib/loader/index.ts +2 -0
  103. package/src/lib/loader/loader.component.spec.ts +58 -0
  104. package/src/lib/loader/loader.component.ts +48 -0
  105. package/src/lib/loader/loader.variants.ts +21 -0
  106. package/src/lib/modal/dialog-ref.ts +19 -0
  107. package/src/lib/modal/dialog.directives.ts +90 -0
  108. package/src/lib/modal/dialog.service.spec.ts +52 -0
  109. package/src/lib/modal/dialog.service.ts +61 -0
  110. package/src/lib/modal/dialog.types.ts +16 -0
  111. package/src/lib/modal/index.ts +11 -0
  112. package/src/lib/navbar/index.ts +7 -0
  113. package/src/lib/navbar/navbar.directives.spec.ts +59 -0
  114. package/src/lib/navbar/navbar.directives.ts +61 -0
  115. package/src/lib/pagination/index.ts +6 -0
  116. package/src/lib/pagination/pagination.component.spec.ts +59 -0
  117. package/src/lib/pagination/pagination.component.ts +144 -0
  118. package/src/lib/pagination/pagination.variants.ts +31 -0
  119. package/src/lib/progress/index.ts +7 -0
  120. package/src/lib/progress/progress.component.spec.ts +117 -0
  121. package/src/lib/progress/progress.component.ts +65 -0
  122. package/src/lib/progress/progress.variants.ts +43 -0
  123. package/src/lib/radial-progress/index.ts +5 -0
  124. package/src/lib/radial-progress/radial-progress.component.spec.ts +41 -0
  125. package/src/lib/radial-progress/radial-progress.component.ts +71 -0
  126. package/src/lib/radio/index.ts +2 -0
  127. package/src/lib/radio/radio.directive.spec.ts +46 -0
  128. package/src/lib/radio/radio.directive.ts +17 -0
  129. package/src/lib/radio/radio.variants.ts +19 -0
  130. package/src/lib/rating/index.ts +2 -0
  131. package/src/lib/rating/rating.component.spec.ts +157 -0
  132. package/src/lib/rating/rating.component.ts +171 -0
  133. package/src/lib/rating/rating.variants.ts +20 -0
  134. package/src/lib/select/index.ts +2 -0
  135. package/src/lib/select/select.component.spec.ts +112 -0
  136. package/src/lib/select/select.component.ts +250 -0
  137. package/src/lib/select/select.variants.ts +19 -0
  138. package/src/lib/sheet/index.ts +10 -0
  139. package/src/lib/sheet/sheet-ref.ts +18 -0
  140. package/src/lib/sheet/sheet.component.spec.ts +67 -0
  141. package/src/lib/sheet/sheet.directives.ts +75 -0
  142. package/src/lib/sheet/sheet.service.ts +100 -0
  143. package/src/lib/sheet/sheet.types.ts +23 -0
  144. package/src/lib/skeleton/index.ts +2 -0
  145. package/src/lib/skeleton/skeleton.directive.spec.ts +63 -0
  146. package/src/lib/skeleton/skeleton.directive.ts +22 -0
  147. package/src/lib/skeleton/skeleton.variants.ts +27 -0
  148. package/src/lib/slider/index.ts +2 -0
  149. package/src/lib/slider/slider.component.spec.ts +104 -0
  150. package/src/lib/slider/slider.component.ts +188 -0
  151. package/src/lib/slider/slider.variants.ts +25 -0
  152. package/src/lib/stat/index.ts +8 -0
  153. package/src/lib/stat/stat.directives.spec.ts +60 -0
  154. package/src/lib/stat/stat.directives.ts +84 -0
  155. package/src/lib/status/index.ts +2 -0
  156. package/src/lib/status/status.directive.spec.ts +43 -0
  157. package/src/lib/status/status.directive.ts +38 -0
  158. package/src/lib/status/status.variants.ts +26 -0
  159. package/src/lib/steps/index.ts +8 -0
  160. package/src/lib/steps/steps.directives.spec.ts +52 -0
  161. package/src/lib/steps/steps.directives.ts +80 -0
  162. package/src/lib/switch/index.ts +2 -0
  163. package/src/lib/switch/switch.component.spec.ts +98 -0
  164. package/src/lib/switch/switch.component.ts +84 -0
  165. package/src/lib/switch/switch.variants.ts +31 -0
  166. package/src/lib/table/index.ts +12 -0
  167. package/src/lib/table/table.directives.spec.ts +111 -0
  168. package/src/lib/table/table.directives.ts +134 -0
  169. package/src/lib/table/table.variants.ts +36 -0
  170. package/src/lib/tabs/index.ts +8 -0
  171. package/src/lib/tabs/tabs.directives.spec.ts +136 -0
  172. package/src/lib/tabs/tabs.directives.ts +130 -0
  173. package/src/lib/tabs/tabs.variants.ts +17 -0
  174. package/src/lib/textarea/index.ts +7 -0
  175. package/src/lib/textarea/textarea.directive.spec.ts +84 -0
  176. package/src/lib/textarea/textarea.directive.ts +72 -0
  177. package/src/lib/textarea/textarea.variants.ts +34 -0
  178. package/src/lib/timeline/index.ts +11 -0
  179. package/src/lib/timeline/timeline.directives.spec.ts +55 -0
  180. package/src/lib/timeline/timeline.directives.ts +90 -0
  181. package/src/lib/toast/index.ts +3 -0
  182. package/src/lib/toast/toast.service.spec.ts +71 -0
  183. package/src/lib/toast/toast.service.ts +60 -0
  184. package/src/lib/toast/toast.variants.ts +38 -0
  185. package/src/lib/toast/toaster.component.spec.ts +38 -0
  186. package/src/lib/toast/toaster.component.ts +82 -0
  187. package/src/lib/toggle/index.ts +2 -0
  188. package/src/lib/toggle/toggle.directive.spec.ts +100 -0
  189. package/src/lib/toggle/toggle.directive.ts +73 -0
  190. package/src/lib/toggle/toggle.variants.ts +25 -0
  191. package/src/lib/tooltip/index.ts +2 -0
  192. package/src/lib/tooltip/tooltip.directive.spec.ts +113 -0
  193. package/src/lib/tooltip/tooltip.directive.ts +131 -0
  194. package/src/lib/tooltip/tooltip.variants.ts +20 -0
  195. package/src/lib/validator/index.ts +5 -0
  196. package/src/lib/validator/validator.directives.spec.ts +47 -0
  197. package/src/lib/validator/validator.directives.ts +52 -0
  198. package/types/sonny-ui-core.d.ts +878 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sonny-ui/core",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.10",
4
4
  "description": "Angular UI component library inspired by shadcn/ui — signals, zoneless, Tailwind CSS v4",
5
5
  "peerDependencies": {
6
6
  "@angular/common": "^21.0.0",
@@ -33,13 +33,14 @@
33
33
  "type": "git",
34
34
  "url": "https://github.com/coci-dev/sonny-ui.git"
35
35
  },
36
- "homepage": "https://github.com/coci-dev/sonny-ui#readme",
36
+ "homepage": "https://coci-dev.github.io/sonny-ui/",
37
37
  "bugs": {
38
38
  "url": "https://github.com/coci-dev/sonny-ui/issues"
39
39
  },
40
- "module": "fesm2022/sonny-ui-core.mjs",
41
- "typings": "types/sonny-ui-core.d.ts",
42
40
  "exports": {
41
+ "./styles/*": {
42
+ "default": "./src/styles/*"
43
+ },
43
44
  "./package.json": {
44
45
  "default": "./package.json"
45
46
  },
@@ -47,5 +48,7 @@
47
48
  "types": "./types/sonny-ui-core.d.ts",
48
49
  "default": "./fesm2022/sonny-ui-core.mjs"
49
50
  }
50
- }
51
+ },
52
+ "module": "fesm2022/sonny-ui-core.mjs",
53
+ "typings": "types/sonny-ui-core.d.ts"
51
54
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/schema",
3
- "id": "SonnyUIAddSchema",
3
+ "$id": "SonnyUIAddSchema",
4
4
  "title": "SonnyUI ng-add schematic",
5
5
  "type": "object",
6
6
  "properties": {
@@ -236,7 +236,7 @@ export function cn(...inputs: ClassValue[]): string {
236
236
  const fs = require('fs');
237
237
  let content = fs.readFileSync(sourcePath, 'utf-8');
238
238
  // Rewrite imports to use local cn utility
239
- content = content.replace(/from ['"]\.\.\/core\/utils\/cn['"]/g, `from '../../utils/cn'`);
239
+ content = content.replace(/from ['"]\.\.\/core\/utils\/cn['"]/g, `from '../utils/cn'`);
240
240
  // Rewrite prefix if custom
241
241
  if (options.prefix && options.prefix !== 'sny') {
242
242
  content = content.replace(/sny/g, options.prefix);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/schema",
3
- "id": "SonnyUIGenerateComponentSchema",
3
+ "$id": "SonnyUIGenerateComponentSchema",
4
4
  "title": "SonnyUI component generator (copy-paste style)",
5
5
  "type": "object",
6
6
  "properties": {
@@ -0,0 +1,173 @@
1
+ import { Component, signal, viewChild } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import {
4
+ SnyAccordionDirective,
5
+ SnyAccordionItemDirective,
6
+ SnyAccordionTriggerDirective,
7
+ SnyAccordionContentDirective,
8
+ } from './accordion.directives';
9
+
10
+ @Component({
11
+ standalone: true,
12
+ imports: [
13
+ SnyAccordionDirective,
14
+ SnyAccordionItemDirective,
15
+ SnyAccordionTriggerDirective,
16
+ SnyAccordionContentDirective,
17
+ ],
18
+ template: `
19
+ <div snyAccordion [multi]="multi()">
20
+ <div snyAccordionItem value="item-1">
21
+ <button snyAccordionTrigger>Item 1</button>
22
+ <div snyAccordionContent><div>Content 1</div></div>
23
+ </div>
24
+ <div snyAccordionItem value="item-2">
25
+ <button snyAccordionTrigger>Item 2</button>
26
+ <div snyAccordionContent><div>Content 2</div></div>
27
+ </div>
28
+ </div>
29
+ `,
30
+ })
31
+ class TestHostComponent {
32
+ multi = signal(false);
33
+ }
34
+
35
+ describe('Accordion Directives', () => {
36
+ let fixture: ComponentFixture<TestHostComponent>;
37
+
38
+ beforeEach(async () => {
39
+ await TestBed.configureTestingModule({
40
+ imports: [TestHostComponent],
41
+ }).compileComponents();
42
+
43
+ fixture = TestBed.createComponent(TestHostComponent);
44
+ fixture.detectChanges();
45
+ });
46
+
47
+ it('should render accordion items', () => {
48
+ const items = fixture.nativeElement.querySelectorAll('[snyAccordionItem]');
49
+ expect(items.length).toBe(2);
50
+ });
51
+
52
+ it('should toggle item on trigger click', () => {
53
+ const trigger = fixture.nativeElement.querySelector('[snyAccordionTrigger]');
54
+ trigger.click();
55
+ fixture.detectChanges();
56
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
57
+ });
58
+
59
+ it('should close previous item in single mode', () => {
60
+ const triggers = fixture.nativeElement.querySelectorAll('[snyAccordionTrigger]');
61
+ triggers[0].click();
62
+ fixture.detectChanges();
63
+ expect(triggers[0].getAttribute('aria-expanded')).toBe('true');
64
+
65
+ triggers[1].click();
66
+ fixture.detectChanges();
67
+ expect(triggers[0].getAttribute('aria-expanded')).toBe('false');
68
+ expect(triggers[1].getAttribute('aria-expanded')).toBe('true');
69
+ });
70
+
71
+ it('should allow multiple open in multi mode', () => {
72
+ fixture.componentInstance.multi.set(true);
73
+ fixture.detectChanges();
74
+
75
+ const triggers = fixture.nativeElement.querySelectorAll('[snyAccordionTrigger]');
76
+ triggers[0].click();
77
+ fixture.detectChanges();
78
+ triggers[1].click();
79
+ fixture.detectChanges();
80
+
81
+ expect(triggers[0].getAttribute('aria-expanded')).toBe('true');
82
+ expect(triggers[1].getAttribute('aria-expanded')).toBe('true');
83
+ });
84
+
85
+ it('should apply content visibility classes', () => {
86
+ const content = fixture.nativeElement.querySelector('[snyAccordionContent]');
87
+ expect(content.className).toContain('grid-rows-[0fr]');
88
+
89
+ const trigger = fixture.nativeElement.querySelector('[snyAccordionTrigger]');
90
+ trigger.click();
91
+ fixture.detectChanges();
92
+
93
+ expect(content.className).toContain('grid-rows-[1fr]');
94
+ });
95
+
96
+ it('should move focus with ArrowDown', () => {
97
+ const triggers = fixture.nativeElement.querySelectorAll('[snyAccordionTrigger]');
98
+ (triggers[0] as HTMLElement).focus();
99
+ triggers[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
100
+ fixture.detectChanges();
101
+ const updated = fixture.nativeElement.querySelectorAll('[snyAccordionTrigger]');
102
+ expect(document.activeElement).toBe(updated[1]);
103
+ });
104
+
105
+ it('should move focus with ArrowUp', () => {
106
+ const triggers = fixture.nativeElement.querySelectorAll('[snyAccordionTrigger]');
107
+ (triggers[1] as HTMLElement).focus();
108
+ triggers[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
109
+ fixture.detectChanges();
110
+ const updated = fixture.nativeElement.querySelectorAll('[snyAccordionTrigger]');
111
+ expect(document.activeElement).toBe(updated[0]);
112
+ });
113
+
114
+ it('should move focus to first with Home', () => {
115
+ const triggers = fixture.nativeElement.querySelectorAll('[snyAccordionTrigger]');
116
+ (triggers[1] as HTMLElement).focus();
117
+ triggers[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
118
+ fixture.detectChanges();
119
+ const updated = fixture.nativeElement.querySelectorAll('[snyAccordionTrigger]');
120
+ expect(document.activeElement).toBe(updated[0]);
121
+ });
122
+
123
+ it('should move focus to last with End', () => {
124
+ const triggers = fixture.nativeElement.querySelectorAll('[snyAccordionTrigger]');
125
+ (triggers[0] as HTMLElement).focus();
126
+ triggers[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
127
+ fixture.detectChanges();
128
+ const updated = fixture.nativeElement.querySelectorAll('[snyAccordionTrigger]');
129
+ expect(document.activeElement).toBe(updated[1]);
130
+ });
131
+ });
132
+
133
+ @Component({
134
+ standalone: true,
135
+ imports: [
136
+ SnyAccordionDirective,
137
+ SnyAccordionItemDirective,
138
+ SnyAccordionTriggerDirective,
139
+ SnyAccordionContentDirective,
140
+ ],
141
+ template: `
142
+ <div snyAccordion #a="snyAccordion">
143
+ <div snyAccordionItem #i="snyAccordionItem" value="item-1">
144
+ <button snyAccordionTrigger>Item 1</button>
145
+ <div snyAccordionContent><div>Content 1</div></div>
146
+ </div>
147
+ </div>
148
+ `,
149
+ })
150
+ class ExportAsHost {
151
+ accordion = viewChild<SnyAccordionDirective>('a');
152
+ item = viewChild<SnyAccordionItemDirective>('i');
153
+ }
154
+
155
+ describe('Accordion exportAs', () => {
156
+ it('should expose snyAccordion via template ref', async () => {
157
+ await TestBed.configureTestingModule({ imports: [ExportAsHost] }).compileComponents();
158
+ const fixture = TestBed.createComponent(ExportAsHost);
159
+ fixture.detectChanges();
160
+ const ref = fixture.componentInstance.accordion();
161
+ expect(ref).toBeTruthy();
162
+ expect(ref!.multi()).toBe(false);
163
+ });
164
+
165
+ it('should expose snyAccordionItem via template ref', async () => {
166
+ await TestBed.configureTestingModule({ imports: [ExportAsHost] }).compileComponents();
167
+ const fixture = TestBed.createComponent(ExportAsHost);
168
+ fixture.detectChanges();
169
+ const ref = fixture.componentInstance.item();
170
+ expect(ref).toBeTruthy();
171
+ expect(ref!.isOpen()).toBe(false);
172
+ });
173
+ });
@@ -0,0 +1,147 @@
1
+ import { Directive, ElementRef, computed, inject, input, signal, InjectionToken } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+
4
+ export const SNY_ACCORDION = new InjectionToken<SnyAccordionDirective>('SnyAccordion');
5
+ export const SNY_ACCORDION_ITEM = new InjectionToken<SnyAccordionItemDirective>('SnyAccordionItem');
6
+
7
+ @Directive({
8
+ selector: '[snyAccordion]',
9
+ standalone: true,
10
+ exportAs: 'snyAccordion',
11
+ providers: [{ provide: SNY_ACCORDION, useExisting: SnyAccordionDirective }],
12
+ host: {
13
+ '[class]': 'computedClass()',
14
+ '(keydown)': 'onKeydown($event)',
15
+ },
16
+ })
17
+ export class SnyAccordionDirective {
18
+ readonly multi = input(false);
19
+ readonly class = input<string>('');
20
+
21
+ private readonly elRef = inject(ElementRef);
22
+ private readonly _openItems = signal(new Set<string>());
23
+
24
+ protected readonly computedClass = computed(() =>
25
+ cn('divide-y divide-border', this.class())
26
+ );
27
+
28
+ isOpen(id: string): boolean {
29
+ return this._openItems().has(id);
30
+ }
31
+
32
+ toggle(id: string): void {
33
+ this._openItems.update(set => {
34
+ const next = new Set(set);
35
+ if (next.has(id)) {
36
+ next.delete(id);
37
+ } else {
38
+ if (!this.multi()) next.clear();
39
+ next.add(id);
40
+ }
41
+ return next;
42
+ });
43
+ }
44
+
45
+ onKeydown(event: KeyboardEvent): void {
46
+ const target = event.target as HTMLElement;
47
+ if (!target.hasAttribute('snyaccordiontrigger') && !target.closest('[snyAccordionTrigger]')) return;
48
+
49
+ const triggers = Array.from(
50
+ (this.elRef.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('[snyAccordionTrigger]')
51
+ );
52
+ if (triggers.length === 0) return;
53
+
54
+ const currentIndex = triggers.indexOf(target.closest('[snyAccordionTrigger]') as HTMLElement || target);
55
+ if (currentIndex === -1) return;
56
+
57
+ let nextIndex: number | null = null;
58
+ switch (event.key) {
59
+ case 'ArrowDown':
60
+ event.preventDefault();
61
+ nextIndex = (currentIndex + 1) % triggers.length;
62
+ break;
63
+ case 'ArrowUp':
64
+ event.preventDefault();
65
+ nextIndex = (currentIndex - 1 + triggers.length) % triggers.length;
66
+ break;
67
+ case 'Home':
68
+ event.preventDefault();
69
+ nextIndex = 0;
70
+ break;
71
+ case 'End':
72
+ event.preventDefault();
73
+ nextIndex = triggers.length - 1;
74
+ break;
75
+ }
76
+ if (nextIndex !== null) {
77
+ triggers[nextIndex].focus();
78
+ }
79
+ }
80
+ }
81
+
82
+ @Directive({
83
+ selector: '[snyAccordionItem]',
84
+ standalone: true,
85
+ exportAs: 'snyAccordionItem',
86
+ providers: [{ provide: SNY_ACCORDION_ITEM, useExisting: SnyAccordionItemDirective }],
87
+ host: { '[class]': 'computedClass()' },
88
+ })
89
+ export class SnyAccordionItemDirective {
90
+ readonly value = input.required<string>();
91
+ readonly class = input<string>('');
92
+ private readonly accordion = inject(SNY_ACCORDION);
93
+
94
+ readonly isOpen = computed(() => this.accordion.isOpen(this.value()));
95
+
96
+ protected readonly computedClass = computed(() =>
97
+ cn('', this.class())
98
+ );
99
+
100
+ toggle(): void {
101
+ this.accordion.toggle(this.value());
102
+ }
103
+ }
104
+
105
+ @Directive({
106
+ selector: '[snyAccordionTrigger]',
107
+ standalone: true,
108
+ host: {
109
+ '[class]': 'computedClass()',
110
+ '[attr.aria-expanded]': 'item.isOpen()',
111
+ 'tabindex': '0',
112
+ '(click)': 'item.toggle()',
113
+ },
114
+ })
115
+ export class SnyAccordionTriggerDirective {
116
+ readonly class = input<string>('');
117
+ readonly item = inject(SNY_ACCORDION_ITEM);
118
+
119
+ protected readonly computedClass = computed(() =>
120
+ cn(
121
+ 'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline cursor-pointer [&>svg]:transition-transform',
122
+ this.item.isOpen() && '[&>svg]:rotate-180',
123
+ this.class()
124
+ )
125
+ );
126
+ }
127
+
128
+ @Directive({
129
+ selector: '[snyAccordionContent]',
130
+ standalone: true,
131
+ host: {
132
+ '[class]': 'computedClass()',
133
+ role: 'region',
134
+ },
135
+ })
136
+ export class SnyAccordionContentDirective {
137
+ readonly class = input<string>('');
138
+ readonly item = inject(SNY_ACCORDION_ITEM);
139
+
140
+ protected readonly computedClass = computed(() =>
141
+ cn(
142
+ 'grid transition-all duration-200',
143
+ this.item.isOpen() ? 'grid-rows-[1fr] opacity-100 pb-4' : 'grid-rows-[0fr] opacity-0 overflow-hidden',
144
+ this.class()
145
+ )
146
+ );
147
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ SnyAccordionDirective,
3
+ SnyAccordionItemDirective,
4
+ SnyAccordionTriggerDirective,
5
+ SnyAccordionContentDirective,
6
+ SNY_ACCORDION,
7
+ SNY_ACCORDION_ITEM,
8
+ } from './accordion.directives';
@@ -0,0 +1,154 @@
1
+ import { Component, signal, viewChild } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyAlertDirective, SnyAlertTitleDirective, SnyAlertDescriptionDirective } from './alert.directives';
4
+ import type { AlertVariant } from './alert.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyAlertDirective, SnyAlertTitleDirective, SnyAlertDescriptionDirective],
9
+ template: `
10
+ <div snyAlert [variant]="variant()" [dismissible]="dismissible()">
11
+ <h5 snyAlertTitle>{{ title() }}</h5>
12
+ <p snyAlertDescription>{{ description() }}</p>
13
+ </div>
14
+ `,
15
+ })
16
+ class TestHostComponent {
17
+ variant = signal<AlertVariant>('default');
18
+ dismissible = signal(false);
19
+ title = signal('Test Title');
20
+ description = signal('Test Description');
21
+ alert = viewChild(SnyAlertDirective);
22
+ }
23
+
24
+ describe('SnyAlertDirective', () => {
25
+ let fixture: ComponentFixture<TestHostComponent>;
26
+ let el: HTMLElement;
27
+
28
+ beforeEach(async () => {
29
+ await TestBed.configureTestingModule({
30
+ imports: [TestHostComponent],
31
+ }).compileComponents();
32
+ fixture = TestBed.createComponent(TestHostComponent);
33
+ fixture.detectChanges();
34
+ el = fixture.nativeElement.querySelector('[snyAlert]');
35
+ });
36
+
37
+ it('should apply default variant classes', () => {
38
+ expect(el.className).toContain('bg-background');
39
+ expect(el.className).toContain('rounded-lg');
40
+ });
41
+
42
+ it('should apply destructive variant classes', () => {
43
+ fixture.componentInstance.variant.set('destructive');
44
+ fixture.detectChanges();
45
+ expect(el.className).toContain('text-destructive');
46
+ });
47
+
48
+ it('should apply success variant classes', () => {
49
+ fixture.componentInstance.variant.set('success');
50
+ fixture.detectChanges();
51
+ expect(el.className).toContain('text-green-700');
52
+ });
53
+
54
+ it('should apply warning variant classes', () => {
55
+ fixture.componentInstance.variant.set('warning');
56
+ fixture.detectChanges();
57
+ expect(el.className).toContain('text-yellow-700');
58
+ });
59
+
60
+ it('should apply info variant classes', () => {
61
+ fixture.componentInstance.variant.set('info');
62
+ fixture.detectChanges();
63
+ expect(el.className).toContain('text-blue-700');
64
+ });
65
+
66
+ it('should set role="status" for default and success', () => {
67
+ expect(el.getAttribute('role')).toBe('status');
68
+ fixture.componentInstance.variant.set('success');
69
+ fixture.detectChanges();
70
+ expect(el.getAttribute('role')).toBe('status');
71
+ });
72
+
73
+ it('should set role="alert" for destructive, warning, info', () => {
74
+ fixture.componentInstance.variant.set('destructive');
75
+ fixture.detectChanges();
76
+ expect(el.getAttribute('role')).toBe('alert');
77
+
78
+ fixture.componentInstance.variant.set('warning');
79
+ fixture.detectChanges();
80
+ expect(el.getAttribute('role')).toBe('alert');
81
+
82
+ fixture.componentInstance.variant.set('info');
83
+ fixture.detectChanges();
84
+ expect(el.getAttribute('role')).toBe('alert');
85
+ });
86
+
87
+ it('should set aria-live="assertive" for destructive/warning', () => {
88
+ fixture.componentInstance.variant.set('destructive');
89
+ fixture.detectChanges();
90
+ expect(el.getAttribute('aria-live')).toBe('assertive');
91
+
92
+ fixture.componentInstance.variant.set('warning');
93
+ fixture.detectChanges();
94
+ expect(el.getAttribute('aria-live')).toBe('assertive');
95
+ });
96
+
97
+ it('should set aria-live="polite" for default/success/info', () => {
98
+ expect(el.getAttribute('aria-live')).toBe('polite');
99
+
100
+ fixture.componentInstance.variant.set('success');
101
+ fixture.detectChanges();
102
+ expect(el.getAttribute('aria-live')).toBe('polite');
103
+
104
+ fixture.componentInstance.variant.set('info');
105
+ fixture.detectChanges();
106
+ expect(el.getAttribute('aria-live')).toBe('polite');
107
+ });
108
+
109
+ it('should be visible by default', () => {
110
+ expect(el.style.display).not.toBe('none');
111
+ });
112
+
113
+ it('should hide when dismiss() is called', () => {
114
+ const alert = fixture.componentInstance.alert();
115
+ alert!.dismiss();
116
+ fixture.detectChanges();
117
+ expect(el.style.display).toBe('none');
118
+ });
119
+ });
120
+
121
+ describe('SnyAlertTitleDirective', () => {
122
+ let fixture: ComponentFixture<TestHostComponent>;
123
+
124
+ beforeEach(async () => {
125
+ await TestBed.configureTestingModule({
126
+ imports: [TestHostComponent],
127
+ }).compileComponents();
128
+ fixture = TestBed.createComponent(TestHostComponent);
129
+ fixture.detectChanges();
130
+ });
131
+
132
+ it('should apply title classes', () => {
133
+ const title = fixture.nativeElement.querySelector('[snyAlertTitle]');
134
+ expect(title.className).toContain('font-medium');
135
+ expect(title.className).toContain('tracking-tight');
136
+ });
137
+ });
138
+
139
+ describe('SnyAlertDescriptionDirective', () => {
140
+ let fixture: ComponentFixture<TestHostComponent>;
141
+
142
+ beforeEach(async () => {
143
+ await TestBed.configureTestingModule({
144
+ imports: [TestHostComponent],
145
+ }).compileComponents();
146
+ fixture = TestBed.createComponent(TestHostComponent);
147
+ fixture.detectChanges();
148
+ });
149
+
150
+ it('should apply description classes', () => {
151
+ const desc = fixture.nativeElement.querySelector('[snyAlertDescription]');
152
+ expect(desc.className).toContain('text-sm');
153
+ });
154
+ });
@@ -0,0 +1,70 @@
1
+ import { Directive, computed, input, signal } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { alertVariants, type AlertVariant } from './alert.variants';
4
+
5
+ @Directive({
6
+ selector: '[snyAlert]',
7
+ exportAs: 'snyAlert',
8
+ standalone: true,
9
+ host: {
10
+ '[class]': 'computedClass()',
11
+ '[attr.role]': 'role()',
12
+ '[attr.aria-live]': 'ariaLive()',
13
+ '[style.display]': 'visible() ? null : "none"',
14
+ },
15
+ })
16
+ export class SnyAlertDirective {
17
+ readonly variant = input<AlertVariant>('default');
18
+ readonly dismissible = input(false);
19
+ readonly class = input<string>('');
20
+
21
+ readonly visible = signal(true);
22
+
23
+ protected readonly role = computed(() => {
24
+ const v = this.variant();
25
+ return v === 'destructive' || v === 'warning' || v === 'info' ? 'alert' : 'status';
26
+ });
27
+
28
+ protected readonly ariaLive = computed(() => {
29
+ const v = this.variant();
30
+ return v === 'destructive' || v === 'warning' ? 'assertive' : 'polite';
31
+ });
32
+
33
+ protected readonly computedClass = computed(() =>
34
+ cn(alertVariants({ variant: this.variant() }), this.class())
35
+ );
36
+
37
+ dismiss(): void {
38
+ this.visible.set(false);
39
+ }
40
+ }
41
+
42
+ @Directive({
43
+ selector: '[snyAlertTitle]',
44
+ standalone: true,
45
+ host: {
46
+ '[class]': 'computedClass()',
47
+ },
48
+ })
49
+ export class SnyAlertTitleDirective {
50
+ readonly class = input<string>('');
51
+
52
+ protected readonly computedClass = computed(() =>
53
+ cn('mb-1 font-medium leading-none tracking-tight', this.class())
54
+ );
55
+ }
56
+
57
+ @Directive({
58
+ selector: '[snyAlertDescription]',
59
+ standalone: true,
60
+ host: {
61
+ '[class]': 'computedClass()',
62
+ },
63
+ })
64
+ export class SnyAlertDescriptionDirective {
65
+ readonly class = input<string>('');
66
+
67
+ protected readonly computedClass = computed(() =>
68
+ cn('text-sm [&_p]:leading-relaxed', this.class())
69
+ );
70
+ }
@@ -0,0 +1,25 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const alertVariants = cva(
4
+ 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default:
9
+ 'bg-background text-foreground border-border',
10
+ destructive:
11
+ 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive bg-destructive/10',
12
+ success:
13
+ 'border-green-500/50 text-green-700 dark:text-green-400 dark:border-green-500 [&>svg]:text-green-600 bg-green-50 dark:bg-green-950/30',
14
+ warning:
15
+ 'border-yellow-500/50 text-yellow-700 dark:text-yellow-400 dark:border-yellow-500 [&>svg]:text-yellow-600 bg-yellow-50 dark:bg-yellow-950/30',
16
+ info: 'border-blue-500/50 text-blue-700 dark:text-blue-400 dark:border-blue-500 [&>svg]:text-blue-600 bg-blue-50 dark:bg-blue-950/30',
17
+ },
18
+ },
19
+ defaultVariants: {
20
+ variant: 'default',
21
+ },
22
+ }
23
+ );
24
+
25
+ export type AlertVariant = 'default' | 'destructive' | 'success' | 'warning' | 'info';
@@ -0,0 +1,6 @@
1
+ export {
2
+ SnyAlertDirective,
3
+ SnyAlertTitleDirective,
4
+ SnyAlertDescriptionDirective,
5
+ } from './alert.directives';
6
+ export { alertVariants, type AlertVariant } from './alert.variants';