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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +101 -32
  2. package/fesm2022/sonny-ui-core.mjs +3031 -42
  3. package/fesm2022/sonny-ui-core.mjs.map +1 -1
  4. package/package.json +8 -5
  5. package/schematics/ng-add/schema.json +1 -1
  6. package/schematics/ng-generate/component/index.js +1 -1
  7. package/schematics/ng-generate/component/schema.json +1 -1
  8. package/src/lib/accordion/accordion.directives.spec.ts +173 -0
  9. package/src/lib/accordion/accordion.directives.ts +147 -0
  10. package/src/lib/accordion/index.ts +8 -0
  11. package/src/lib/alert/alert.directives.spec.ts +154 -0
  12. package/src/lib/alert/alert.directives.ts +70 -0
  13. package/src/lib/alert/alert.variants.ts +25 -0
  14. package/src/lib/alert/index.ts +6 -0
  15. package/src/lib/avatar/avatar.component.spec.ts +75 -0
  16. package/src/lib/avatar/avatar.component.ts +44 -0
  17. package/src/lib/avatar/avatar.variants.ts +26 -0
  18. package/src/lib/avatar/index.ts +2 -0
  19. package/src/lib/badge/badge.directive.spec.ts +74 -0
  20. package/src/lib/badge/badge.directive.ts +18 -0
  21. package/src/lib/badge/badge.variants.ts +29 -0
  22. package/src/lib/badge/index.ts +2 -0
  23. package/src/lib/breadcrumb/breadcrumb.directives.spec.ts +80 -0
  24. package/src/lib/breadcrumb/breadcrumb.directives.ts +84 -0
  25. package/src/lib/breadcrumb/index.ts +8 -0
  26. package/src/lib/button/button.directive.spec.ts +92 -0
  27. package/src/lib/button/button.directive.ts +29 -0
  28. package/src/lib/button/button.variants.ts +30 -0
  29. package/src/lib/button/index.ts +2 -0
  30. package/src/lib/button-group/button-group.directive.spec.ts +46 -0
  31. package/src/lib/button-group/button-group.directive.ts +20 -0
  32. package/src/lib/button-group/button-group.variants.ts +18 -0
  33. package/src/lib/button-group/index.ts +2 -0
  34. package/src/lib/calendar/calendar.component.spec.ts +105 -0
  35. package/src/lib/calendar/calendar.component.ts +231 -0
  36. package/src/lib/calendar/index.ts +1 -0
  37. package/src/lib/card/card.directives.spec.ts +104 -0
  38. package/src/lib/card/card.directives.ts +78 -0
  39. package/src/lib/card/card.variants.ts +28 -0
  40. package/src/lib/card/index.ts +9 -0
  41. package/src/lib/carousel/carousel.directives.spec.ts +85 -0
  42. package/src/lib/carousel/carousel.directives.ts +164 -0
  43. package/src/lib/carousel/index.ts +8 -0
  44. package/src/lib/chat-bubble/chat-bubble.directives.spec.ts +52 -0
  45. package/src/lib/chat-bubble/chat-bubble.directives.ts +102 -0
  46. package/src/lib/chat-bubble/index.ts +11 -0
  47. package/src/lib/checkbox/checkbox.directive.spec.ts +57 -0
  48. package/src/lib/checkbox/checkbox.directive.ts +17 -0
  49. package/src/lib/checkbox/checkbox.variants.ts +19 -0
  50. package/src/lib/checkbox/index.ts +2 -0
  51. package/src/lib/combobox/combobox.component.spec.ts +151 -0
  52. package/src/lib/combobox/combobox.component.ts +279 -0
  53. package/src/lib/combobox/combobox.variants.ts +19 -0
  54. package/src/lib/combobox/index.ts +2 -0
  55. package/src/lib/diff/diff.component.spec.ts +47 -0
  56. package/src/lib/diff/diff.component.ts +83 -0
  57. package/src/lib/diff/index.ts +1 -0
  58. package/src/lib/divider/divider.component.spec.ts +48 -0
  59. package/src/lib/divider/divider.component.ts +52 -0
  60. package/src/lib/divider/divider.variants.ts +22 -0
  61. package/src/lib/divider/index.ts +2 -0
  62. package/src/lib/dock/dock.directives.spec.ts +85 -0
  63. package/src/lib/dock/dock.directives.ts +83 -0
  64. package/src/lib/dock/index.ts +1 -0
  65. package/src/lib/drawer/drawer.directives.spec.ts +62 -0
  66. package/src/lib/drawer/drawer.directives.ts +83 -0
  67. package/src/lib/drawer/index.ts +8 -0
  68. package/src/lib/dropdown/dropdown.directives.spec.ts +106 -0
  69. package/src/lib/dropdown/dropdown.directives.ts +143 -0
  70. package/src/lib/dropdown/dropdown.variants.ts +27 -0
  71. package/src/lib/dropdown/index.ts +15 -0
  72. package/src/lib/fab/fab.directives.spec.ts +60 -0
  73. package/src/lib/fab/fab.directives.ts +80 -0
  74. package/src/lib/fab/index.ts +8 -0
  75. package/src/lib/fieldset/fieldset.directives.spec.ts +74 -0
  76. package/src/lib/fieldset/fieldset.directives.ts +52 -0
  77. package/src/lib/fieldset/fieldset.variants.ts +15 -0
  78. package/src/lib/fieldset/index.ts +6 -0
  79. package/src/lib/file-input/file-input.component.spec.ts +114 -0
  80. package/src/lib/file-input/file-input.component.ts +168 -0
  81. package/src/lib/file-input/file-input.variants.ts +25 -0
  82. package/src/lib/file-input/index.ts +6 -0
  83. package/src/lib/indicator/index.ts +6 -0
  84. package/src/lib/indicator/indicator.directives.spec.ts +64 -0
  85. package/src/lib/indicator/indicator.directives.ts +61 -0
  86. package/src/lib/input/index.ts +3 -0
  87. package/src/lib/input/input.directive.spec.ts +103 -0
  88. package/src/lib/input/input.directive.ts +26 -0
  89. package/src/lib/input/input.variants.ts +42 -0
  90. package/src/lib/input/label.directive.ts +17 -0
  91. package/src/lib/kbd/index.ts +2 -0
  92. package/src/lib/kbd/kbd.directive.spec.ts +42 -0
  93. package/src/lib/kbd/kbd.directive.ts +19 -0
  94. package/src/lib/kbd/kbd.variants.ts +19 -0
  95. package/src/lib/link/index.ts +2 -0
  96. package/src/lib/link/link.directive.spec.ts +41 -0
  97. package/src/lib/link/link.directive.ts +19 -0
  98. package/src/lib/link/link.variants.ts +20 -0
  99. package/src/lib/list/index.ts +8 -0
  100. package/src/lib/list/list.directives.spec.ts +65 -0
  101. package/src/lib/list/list.directives.ts +86 -0
  102. package/src/lib/loader/index.ts +2 -0
  103. package/src/lib/loader/loader.component.spec.ts +58 -0
  104. package/src/lib/loader/loader.component.ts +48 -0
  105. package/src/lib/loader/loader.variants.ts +21 -0
  106. package/src/lib/modal/dialog-ref.ts +19 -0
  107. package/src/lib/modal/dialog.directives.ts +90 -0
  108. package/src/lib/modal/dialog.service.spec.ts +52 -0
  109. package/src/lib/modal/dialog.service.ts +61 -0
  110. package/src/lib/modal/dialog.types.ts +16 -0
  111. package/src/lib/modal/index.ts +11 -0
  112. package/src/lib/navbar/index.ts +7 -0
  113. package/src/lib/navbar/navbar.directives.spec.ts +59 -0
  114. package/src/lib/navbar/navbar.directives.ts +61 -0
  115. package/src/lib/pagination/index.ts +6 -0
  116. package/src/lib/pagination/pagination.component.spec.ts +59 -0
  117. package/src/lib/pagination/pagination.component.ts +144 -0
  118. package/src/lib/pagination/pagination.variants.ts +31 -0
  119. package/src/lib/progress/index.ts +7 -0
  120. package/src/lib/progress/progress.component.spec.ts +117 -0
  121. package/src/lib/progress/progress.component.ts +65 -0
  122. package/src/lib/progress/progress.variants.ts +43 -0
  123. package/src/lib/radial-progress/index.ts +5 -0
  124. package/src/lib/radial-progress/radial-progress.component.spec.ts +41 -0
  125. package/src/lib/radial-progress/radial-progress.component.ts +71 -0
  126. package/src/lib/radio/index.ts +2 -0
  127. package/src/lib/radio/radio.directive.spec.ts +46 -0
  128. package/src/lib/radio/radio.directive.ts +17 -0
  129. package/src/lib/radio/radio.variants.ts +19 -0
  130. package/src/lib/rating/index.ts +2 -0
  131. package/src/lib/rating/rating.component.spec.ts +157 -0
  132. package/src/lib/rating/rating.component.ts +171 -0
  133. package/src/lib/rating/rating.variants.ts +20 -0
  134. package/src/lib/select/index.ts +2 -0
  135. package/src/lib/select/select.component.spec.ts +112 -0
  136. package/src/lib/select/select.component.ts +250 -0
  137. package/src/lib/select/select.variants.ts +19 -0
  138. package/src/lib/sheet/index.ts +10 -0
  139. package/src/lib/sheet/sheet-ref.ts +18 -0
  140. package/src/lib/sheet/sheet.component.spec.ts +67 -0
  141. package/src/lib/sheet/sheet.directives.ts +75 -0
  142. package/src/lib/sheet/sheet.service.ts +100 -0
  143. package/src/lib/sheet/sheet.types.ts +23 -0
  144. package/src/lib/skeleton/index.ts +2 -0
  145. package/src/lib/skeleton/skeleton.directive.spec.ts +63 -0
  146. package/src/lib/skeleton/skeleton.directive.ts +22 -0
  147. package/src/lib/skeleton/skeleton.variants.ts +27 -0
  148. package/src/lib/slider/index.ts +2 -0
  149. package/src/lib/slider/slider.component.spec.ts +104 -0
  150. package/src/lib/slider/slider.component.ts +188 -0
  151. package/src/lib/slider/slider.variants.ts +25 -0
  152. package/src/lib/stat/index.ts +8 -0
  153. package/src/lib/stat/stat.directives.spec.ts +60 -0
  154. package/src/lib/stat/stat.directives.ts +84 -0
  155. package/src/lib/status/index.ts +2 -0
  156. package/src/lib/status/status.directive.spec.ts +43 -0
  157. package/src/lib/status/status.directive.ts +38 -0
  158. package/src/lib/status/status.variants.ts +26 -0
  159. package/src/lib/steps/index.ts +8 -0
  160. package/src/lib/steps/steps.directives.spec.ts +52 -0
  161. package/src/lib/steps/steps.directives.ts +80 -0
  162. package/src/lib/switch/index.ts +2 -0
  163. package/src/lib/switch/switch.component.spec.ts +98 -0
  164. package/src/lib/switch/switch.component.ts +84 -0
  165. package/src/lib/switch/switch.variants.ts +31 -0
  166. package/src/lib/table/index.ts +12 -0
  167. package/src/lib/table/table.directives.spec.ts +111 -0
  168. package/src/lib/table/table.directives.ts +134 -0
  169. package/src/lib/table/table.variants.ts +36 -0
  170. package/src/lib/tabs/index.ts +8 -0
  171. package/src/lib/tabs/tabs.directives.spec.ts +136 -0
  172. package/src/lib/tabs/tabs.directives.ts +130 -0
  173. package/src/lib/tabs/tabs.variants.ts +17 -0
  174. package/src/lib/textarea/index.ts +7 -0
  175. package/src/lib/textarea/textarea.directive.spec.ts +84 -0
  176. package/src/lib/textarea/textarea.directive.ts +72 -0
  177. package/src/lib/textarea/textarea.variants.ts +34 -0
  178. package/src/lib/timeline/index.ts +11 -0
  179. package/src/lib/timeline/timeline.directives.spec.ts +55 -0
  180. package/src/lib/timeline/timeline.directives.ts +90 -0
  181. package/src/lib/toast/index.ts +3 -0
  182. package/src/lib/toast/toast.service.spec.ts +71 -0
  183. package/src/lib/toast/toast.service.ts +60 -0
  184. package/src/lib/toast/toast.variants.ts +38 -0
  185. package/src/lib/toast/toaster.component.spec.ts +38 -0
  186. package/src/lib/toast/toaster.component.ts +82 -0
  187. package/src/lib/toggle/index.ts +2 -0
  188. package/src/lib/toggle/toggle.directive.spec.ts +100 -0
  189. package/src/lib/toggle/toggle.directive.ts +73 -0
  190. package/src/lib/toggle/toggle.variants.ts +25 -0
  191. package/src/lib/tooltip/index.ts +2 -0
  192. package/src/lib/tooltip/tooltip.directive.spec.ts +113 -0
  193. package/src/lib/tooltip/tooltip.directive.ts +131 -0
  194. package/src/lib/tooltip/tooltip.variants.ts +20 -0
  195. package/src/lib/validator/index.ts +5 -0
  196. package/src/lib/validator/validator.directives.spec.ts +47 -0
  197. package/src/lib/validator/validator.directives.ts +52 -0
  198. package/types/sonny-ui-core.d.ts +878 -11
@@ -0,0 +1,111 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import {
4
+ SnyTableDirective,
5
+ SnyTableHeaderDirective,
6
+ SnyTableBodyDirective,
7
+ SnyTableRowDirective,
8
+ SnyTableHeadDirective,
9
+ SnyTableCellDirective,
10
+ SnyTableFooterDirective,
11
+ SnyTableCaptionDirective,
12
+ } from './table.directives';
13
+ import type { TableVariant, TableDensity } from './table.variants';
14
+
15
+ @Component({
16
+ standalone: true,
17
+ imports: [
18
+ SnyTableDirective, SnyTableHeaderDirective, SnyTableBodyDirective,
19
+ SnyTableRowDirective, SnyTableHeadDirective, SnyTableCellDirective,
20
+ SnyTableFooterDirective, SnyTableCaptionDirective,
21
+ ],
22
+ template: `
23
+ <table snyTable [variant]="variant()" [density]="density()" [hoverable]="hoverable()" [stickyHeader]="stickyHeader()">
24
+ <caption snyTableCaption>Test table</caption>
25
+ <thead snyTableHeader>
26
+ <tr snyTableRow>
27
+ <th snyTableHead>Name</th>
28
+ <th snyTableHead>Value</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody snyTableBody>
32
+ <tr snyTableRow>
33
+ <td snyTableCell>A</td>
34
+ <td snyTableCell>1</td>
35
+ </tr>
36
+ </tbody>
37
+ <tfoot snyTableFooter>
38
+ <tr snyTableRow>
39
+ <td snyTableCell>Total</td>
40
+ <td snyTableCell>1</td>
41
+ </tr>
42
+ </tfoot>
43
+ </table>
44
+ `,
45
+ })
46
+ class TestHostComponent {
47
+ variant = signal<TableVariant>('default');
48
+ density = signal<TableDensity>('normal');
49
+ hoverable = signal(false);
50
+ stickyHeader = signal(false);
51
+ }
52
+
53
+ describe('Table Directives', () => {
54
+ let fixture: ComponentFixture<TestHostComponent>;
55
+ let table: HTMLTableElement;
56
+
57
+ beforeEach(async () => {
58
+ await TestBed.configureTestingModule({
59
+ imports: [TestHostComponent],
60
+ }).compileComponents();
61
+
62
+ fixture = TestBed.createComponent(TestHostComponent);
63
+ fixture.detectChanges();
64
+ table = fixture.nativeElement.querySelector('table');
65
+ });
66
+
67
+ it('should apply base table classes', () => {
68
+ expect(table.className).toContain('w-full');
69
+ expect(table.className).toContain('caption-bottom');
70
+ });
71
+
72
+ it('should apply striped variant', () => {
73
+ fixture.componentInstance.variant.set('striped');
74
+ fixture.detectChanges();
75
+ expect(table.className).toContain('nth-child');
76
+ });
77
+
78
+ it('should apply bordered variant', () => {
79
+ fixture.componentInstance.variant.set('bordered');
80
+ fixture.detectChanges();
81
+ expect(table.className).toContain('border');
82
+ });
83
+
84
+ it('should apply compact density to cells', () => {
85
+ fixture.componentInstance.density.set('compact');
86
+ fixture.detectChanges();
87
+ const th = fixture.nativeElement.querySelector('th');
88
+ expect(th.className).toContain('px-2');
89
+ expect(th.className).toContain('text-xs');
90
+ });
91
+
92
+ it('should apply hoverable to rows', () => {
93
+ fixture.componentInstance.hoverable.set(true);
94
+ fixture.detectChanges();
95
+ const row = fixture.nativeElement.querySelector('tbody tr');
96
+ expect(row.className).toContain('hover:bg-muted/50');
97
+ });
98
+
99
+ it('should apply sticky header', () => {
100
+ fixture.componentInstance.stickyHeader.set(true);
101
+ fixture.detectChanges();
102
+ const thead = fixture.nativeElement.querySelector('thead');
103
+ expect(thead.className).toContain('sticky');
104
+ });
105
+
106
+ it('should render caption', () => {
107
+ const caption = fixture.nativeElement.querySelector('caption');
108
+ expect(caption.className).toContain('text-muted-foreground');
109
+ expect(caption.textContent).toContain('Test table');
110
+ });
111
+ });
@@ -0,0 +1,134 @@
1
+ import { Directive, computed, inject, input, InjectionToken } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { tableVariants, tableCellVariants, type TableVariant, type TableDensity } from './table.variants';
4
+
5
+ export const SNY_TABLE = new InjectionToken<SnyTableDirective>('SnyTable');
6
+
7
+ @Directive({
8
+ selector: 'table[snyTable]',
9
+ standalone: true,
10
+ providers: [{ provide: SNY_TABLE, useExisting: SnyTableDirective }],
11
+ host: { '[class]': 'computedClass()' },
12
+ })
13
+ export class SnyTableDirective {
14
+ readonly variant = input<TableVariant>('default');
15
+ readonly density = input<TableDensity>('normal');
16
+ readonly hoverable = input(false);
17
+ readonly stickyHeader = input(false);
18
+ readonly class = input<string>('');
19
+
20
+ protected readonly computedClass = computed(() =>
21
+ cn(tableVariants({ variant: this.variant() }), this.class())
22
+ );
23
+ }
24
+
25
+ @Directive({
26
+ selector: 'thead[snyTableHeader]',
27
+ standalone: true,
28
+ host: { '[class]': 'computedClass()' },
29
+ })
30
+ export class SnyTableHeaderDirective {
31
+ readonly class = input<string>('');
32
+ private readonly table = inject(SNY_TABLE, { optional: true });
33
+
34
+ protected readonly computedClass = computed(() =>
35
+ cn(
36
+ '[&_tr]:border-b',
37
+ this.table?.stickyHeader() ? 'sticky top-0 z-10 bg-background' : '',
38
+ this.class()
39
+ )
40
+ );
41
+ }
42
+
43
+ @Directive({
44
+ selector: 'tbody[snyTableBody]',
45
+ standalone: true,
46
+ host: { '[class]': 'computedClass()' },
47
+ })
48
+ export class SnyTableBodyDirective {
49
+ readonly class = input<string>('');
50
+
51
+ protected readonly computedClass = computed(() =>
52
+ cn('[&_tr:last-child]:border-0', this.class())
53
+ );
54
+ }
55
+
56
+ @Directive({
57
+ selector: 'tr[snyTableRow]',
58
+ standalone: true,
59
+ host: { '[class]': 'computedClass()' },
60
+ })
61
+ export class SnyTableRowDirective {
62
+ readonly class = input<string>('');
63
+ private readonly table = inject(SNY_TABLE, { optional: true });
64
+
65
+ protected readonly computedClass = computed(() =>
66
+ cn(
67
+ 'border-b border-border transition-colors data-[state=selected]:bg-muted',
68
+ this.table?.hoverable() ? 'hover:bg-muted/50' : '',
69
+ this.class()
70
+ )
71
+ );
72
+ }
73
+
74
+ @Directive({
75
+ selector: 'th[snyTableHead]',
76
+ standalone: true,
77
+ host: { '[class]': 'computedClass()' },
78
+ })
79
+ export class SnyTableHeadDirective {
80
+ readonly class = input<string>('');
81
+ private readonly table = inject(SNY_TABLE, { optional: true });
82
+
83
+ protected readonly computedClass = computed(() =>
84
+ cn(
85
+ 'text-left font-medium text-muted-foreground [&[align=center]]:text-center [&[align=right]]:text-right',
86
+ tableCellVariants({ density: this.table?.density() ?? 'normal' }),
87
+ this.class()
88
+ )
89
+ );
90
+ }
91
+
92
+ @Directive({
93
+ selector: 'td[snyTableCell]',
94
+ standalone: true,
95
+ host: { '[class]': 'computedClass()' },
96
+ })
97
+ export class SnyTableCellDirective {
98
+ readonly class = input<string>('');
99
+ private readonly table = inject(SNY_TABLE, { optional: true });
100
+
101
+ protected readonly computedClass = computed(() =>
102
+ cn(
103
+ '[&[align=center]]:text-center [&[align=right]]:text-right',
104
+ tableCellVariants({ density: this.table?.density() ?? 'normal' }),
105
+ this.class()
106
+ )
107
+ );
108
+ }
109
+
110
+ @Directive({
111
+ selector: 'tfoot[snyTableFooter]',
112
+ standalone: true,
113
+ host: { '[class]': 'computedClass()' },
114
+ })
115
+ export class SnyTableFooterDirective {
116
+ readonly class = input<string>('');
117
+
118
+ protected readonly computedClass = computed(() =>
119
+ cn('border-t border-border font-medium [&>tr]:last:border-b-0', this.class())
120
+ );
121
+ }
122
+
123
+ @Directive({
124
+ selector: 'caption[snyTableCaption]',
125
+ standalone: true,
126
+ host: { '[class]': 'computedClass()' },
127
+ })
128
+ export class SnyTableCaptionDirective {
129
+ readonly class = input<string>('');
130
+
131
+ protected readonly computedClass = computed(() =>
132
+ cn('mt-4 text-sm text-muted-foreground', this.class())
133
+ );
134
+ }
@@ -0,0 +1,36 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const tableVariants = cva(
4
+ 'w-full caption-bottom text-sm border-collapse',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default: '',
9
+ striped: '[&_tbody_tr:nth-child(even)]:bg-muted/50',
10
+ bordered: 'border border-border [&_th]:border [&_th]:border-border [&_td]:border [&_td]:border-border',
11
+ },
12
+ },
13
+ defaultVariants: {
14
+ variant: 'default',
15
+ },
16
+ }
17
+ );
18
+
19
+ export const tableCellVariants = cva(
20
+ '',
21
+ {
22
+ variants: {
23
+ density: {
24
+ compact: 'px-2 py-1 text-xs',
25
+ normal: 'px-4 py-3 text-sm',
26
+ comfortable: 'px-6 py-4 text-base',
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ density: 'normal',
31
+ },
32
+ }
33
+ );
34
+
35
+ export type TableVariant = 'default' | 'striped' | 'bordered';
36
+ export type TableDensity = 'compact' | 'normal' | 'comfortable';
@@ -0,0 +1,8 @@
1
+ export {
2
+ SnyTabsDirective,
3
+ SnyTabsListDirective,
4
+ SnyTabsTriggerDirective,
5
+ SnyTabsContentDirective,
6
+ SNY_TABS,
7
+ } from './tabs.directives';
8
+ export { tabsListVariants, tabsTriggerVariants } from './tabs.variants';
@@ -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,130 @@
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
+ standalone: true,
9
+ exportAs: 'snyTabs',
10
+ providers: [{ provide: SNY_TABS, useExisting: SnyTabsDirective }],
11
+ host: { '[class]': 'computedClass()' },
12
+ })
13
+ export class SnyTabsDirective {
14
+ readonly value = model<string>('');
15
+ readonly class = input<string>('');
16
+
17
+ protected readonly computedClass = computed(() =>
18
+ cn('', this.class())
19
+ );
20
+
21
+ select(value: string): void {
22
+ this.value.set(value);
23
+ }
24
+ }
25
+
26
+ @Directive({
27
+ selector: '[snyTabsList]',
28
+ standalone: true,
29
+ host: {
30
+ role: 'tablist',
31
+ '[class]': 'computedClass()',
32
+ '(keydown)': 'onKeydown($event)',
33
+ },
34
+ })
35
+ export class SnyTabsListDirective {
36
+ readonly class = input<string>('');
37
+ private readonly elRef = inject(ElementRef);
38
+
39
+ protected readonly computedClass = computed(() =>
40
+ cn(
41
+ 'inline-flex h-10 items-center justify-center rounded-sm bg-muted p-1 text-muted-foreground',
42
+ this.class()
43
+ )
44
+ );
45
+
46
+ onKeydown(event: KeyboardEvent): void {
47
+ const triggers = Array.from(
48
+ (this.elRef.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('[role="tab"]')
49
+ );
50
+ if (triggers.length === 0) return;
51
+
52
+ const currentIndex = triggers.indexOf(document.activeElement as HTMLElement);
53
+ if (currentIndex === -1) return;
54
+
55
+ let nextIndex: number | null = null;
56
+ switch (event.key) {
57
+ case 'ArrowRight':
58
+ case 'ArrowDown':
59
+ event.preventDefault();
60
+ nextIndex = (currentIndex + 1) % triggers.length;
61
+ break;
62
+ case 'ArrowLeft':
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: '[snyTabsTrigger]',
84
+ standalone: true,
85
+ host: {
86
+ role: 'tab',
87
+ '[class]': 'computedClass()',
88
+ '[attr.aria-selected]': 'isActive()',
89
+ '[attr.tabindex]': 'isActive() ? 0 : -1',
90
+ '(click)': 'tabs.select(value())',
91
+ },
92
+ })
93
+ export class SnyTabsTriggerDirective {
94
+ readonly value = input.required<string>();
95
+ readonly class = input<string>('');
96
+ readonly tabs = inject(SNY_TABS);
97
+
98
+ readonly isActive = computed(() => this.tabs.value() === this.value());
99
+
100
+ protected readonly computedClass = computed(() =>
101
+ cn(
102
+ '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',
103
+ this.isActive()
104
+ ? 'bg-background text-foreground shadow-sm'
105
+ : 'hover:bg-background/50',
106
+ this.class()
107
+ )
108
+ );
109
+ }
110
+
111
+ @Directive({
112
+ selector: '[snyTabsContent]',
113
+ standalone: true,
114
+ host: {
115
+ role: 'tabpanel',
116
+ '[class]': 'computedClass()',
117
+ '[style.display]': 'isActive() ? null : "none"',
118
+ },
119
+ })
120
+ export class SnyTabsContentDirective {
121
+ readonly value = input.required<string>();
122
+ readonly class = input<string>('');
123
+ private readonly tabs = inject(SNY_TABS);
124
+
125
+ readonly isActive = computed(() => this.tabs.value() === this.value());
126
+
127
+ protected readonly computedClass = computed(() =>
128
+ 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())
129
+ );
130
+ }
@@ -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,7 @@
1
+ export { SnyTextareaDirective } from './textarea.directive';
2
+ export {
3
+ textareaVariants,
4
+ type TextareaVariant,
5
+ type TextareaSize,
6
+ type TextareaResize,
7
+ } from './textarea.variants';
@@ -0,0 +1,84 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, type ComponentFixture } from '@angular/core/testing';
3
+ import { SnyTextareaDirective } from './textarea.directive';
4
+ import type { TextareaVariant, TextareaSize, TextareaResize } from './textarea.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyTextareaDirective],
9
+ template: `
10
+ <textarea
11
+ snyTextarea
12
+ [variant]="variant()"
13
+ [textareaSize]="textareaSize()"
14
+ [resize]="resize()"
15
+ [autoResize]="autoResize()"
16
+ ></textarea>
17
+ `,
18
+ })
19
+ class TestHostComponent {
20
+ variant = signal<TextareaVariant>('default');
21
+ textareaSize = signal<TextareaSize>('md');
22
+ resize = signal<TextareaResize>('vertical');
23
+ autoResize = signal(false);
24
+ }
25
+
26
+ describe('SnyTextareaDirective', () => {
27
+ let fixture: ComponentFixture<TestHostComponent>;
28
+ let el: HTMLTextAreaElement;
29
+
30
+ beforeEach(async () => {
31
+ await TestBed.configureTestingModule({
32
+ imports: [TestHostComponent],
33
+ }).compileComponents();
34
+ fixture = TestBed.createComponent(TestHostComponent);
35
+ fixture.detectChanges();
36
+ el = fixture.nativeElement.querySelector('textarea');
37
+ });
38
+
39
+ it('should apply default variant classes', () => {
40
+ expect(el.className).toContain('border-input');
41
+ expect(el.className).toContain('rounded-md');
42
+ });
43
+
44
+ it('should apply error variant classes', () => {
45
+ fixture.componentInstance.variant.set('error');
46
+ fixture.detectChanges();
47
+ expect(el.className).toContain('border-destructive');
48
+ });
49
+
50
+ it('should set aria-invalid for error variant', () => {
51
+ expect(el.getAttribute('aria-invalid')).toBeNull();
52
+ fixture.componentInstance.variant.set('error');
53
+ fixture.detectChanges();
54
+ expect(el.getAttribute('aria-invalid')).toBe('true');
55
+ });
56
+
57
+ it('should apply small size', () => {
58
+ fixture.componentInstance.textareaSize.set('sm');
59
+ fixture.detectChanges();
60
+ expect(el.className).toContain('text-xs');
61
+ });
62
+
63
+ it('should apply large size', () => {
64
+ fixture.componentInstance.textareaSize.set('lg');
65
+ fixture.detectChanges();
66
+ expect(el.className).toContain('text-base');
67
+ });
68
+
69
+ it('should apply resize-none when resize is "none"', () => {
70
+ fixture.componentInstance.resize.set('none');
71
+ fixture.detectChanges();
72
+ expect(el.className).toContain('resize-none');
73
+ });
74
+
75
+ it('should apply resize-y by default', () => {
76
+ expect(el.className).toContain('resize-y');
77
+ });
78
+
79
+ it('should force resize-none when autoResize is true', () => {
80
+ fixture.componentInstance.autoResize.set(true);
81
+ fixture.detectChanges();
82
+ expect(el.className).toContain('resize-none');
83
+ });
84
+ });