@sonny-ui/core 0.1.0-alpha.6 → 0.1.0-alpha.8

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 (94) hide show
  1. package/README.md +1 -1
  2. package/package.json +7 -4
  3. package/src/lib/accordion/accordion.directives.spec.ts +95 -0
  4. package/src/lib/accordion/accordion.directives.ts +104 -0
  5. package/src/lib/accordion/index.ts +8 -0
  6. package/src/lib/avatar/avatar.component.spec.ts +75 -0
  7. package/src/lib/avatar/avatar.component.ts +43 -0
  8. package/src/lib/avatar/avatar.variants.ts +26 -0
  9. package/src/lib/avatar/index.ts +2 -0
  10. package/src/lib/badge/badge.directive.spec.ts +74 -0
  11. package/src/lib/badge/badge.directive.ts +18 -0
  12. package/src/lib/badge/badge.variants.ts +29 -0
  13. package/src/lib/badge/index.ts +2 -0
  14. package/src/lib/breadcrumb/breadcrumb.directives.spec.ts +80 -0
  15. package/src/lib/breadcrumb/breadcrumb.directives.ts +84 -0
  16. package/src/lib/breadcrumb/index.ts +8 -0
  17. package/src/lib/button/button.directive.spec.ts +92 -0
  18. package/src/lib/button/button.directive.ts +29 -0
  19. package/src/lib/button/button.variants.ts +30 -0
  20. package/src/lib/button/index.ts +2 -0
  21. package/src/lib/button-group/button-group.directive.spec.ts +46 -0
  22. package/src/lib/button-group/button-group.directive.ts +20 -0
  23. package/src/lib/button-group/button-group.variants.ts +18 -0
  24. package/src/lib/button-group/index.ts +2 -0
  25. package/src/lib/card/card.directives.spec.ts +104 -0
  26. package/src/lib/card/card.directives.ts +78 -0
  27. package/src/lib/card/card.variants.ts +28 -0
  28. package/src/lib/card/index.ts +9 -0
  29. package/src/lib/checkbox/checkbox.directive.spec.ts +57 -0
  30. package/src/lib/checkbox/checkbox.directive.ts +17 -0
  31. package/src/lib/checkbox/checkbox.variants.ts +19 -0
  32. package/src/lib/checkbox/index.ts +2 -0
  33. package/src/lib/combobox/combobox.component.spec.ts +93 -0
  34. package/src/lib/combobox/combobox.component.ts +236 -0
  35. package/src/lib/combobox/combobox.variants.ts +19 -0
  36. package/src/lib/combobox/index.ts +2 -0
  37. package/src/lib/input/index.ts +3 -0
  38. package/src/lib/input/input.directive.spec.ts +103 -0
  39. package/src/lib/input/input.directive.ts +26 -0
  40. package/src/lib/input/input.variants.ts +42 -0
  41. package/src/lib/input/label.directive.ts +17 -0
  42. package/src/lib/loader/index.ts +2 -0
  43. package/src/lib/loader/loader.component.spec.ts +58 -0
  44. package/src/lib/loader/loader.component.ts +47 -0
  45. package/src/lib/loader/loader.variants.ts +21 -0
  46. package/src/lib/modal/dialog-ref.ts +19 -0
  47. package/src/lib/modal/dialog.directives.ts +90 -0
  48. package/src/lib/modal/dialog.service.spec.ts +52 -0
  49. package/src/lib/modal/dialog.service.ts +61 -0
  50. package/src/lib/modal/dialog.types.ts +16 -0
  51. package/src/lib/modal/index.ts +11 -0
  52. package/src/lib/radio/index.ts +2 -0
  53. package/src/lib/radio/radio.directive.spec.ts +46 -0
  54. package/src/lib/radio/radio.directive.ts +17 -0
  55. package/src/lib/radio/radio.variants.ts +19 -0
  56. package/src/lib/select/index.ts +2 -0
  57. package/src/lib/select/select.component.spec.ts +56 -0
  58. package/src/lib/select/select.component.ts +206 -0
  59. package/src/lib/select/select.variants.ts +19 -0
  60. package/src/lib/sheet/index.ts +10 -0
  61. package/src/lib/sheet/sheet-ref.ts +18 -0
  62. package/src/lib/sheet/sheet.component.spec.ts +67 -0
  63. package/src/lib/sheet/sheet.directives.ts +75 -0
  64. package/src/lib/sheet/sheet.service.ts +100 -0
  65. package/src/lib/sheet/sheet.types.ts +23 -0
  66. package/src/lib/skeleton/index.ts +2 -0
  67. package/src/lib/skeleton/skeleton.directive.spec.ts +55 -0
  68. package/src/lib/skeleton/skeleton.directive.ts +18 -0
  69. package/src/lib/skeleton/skeleton.variants.ts +27 -0
  70. package/src/lib/slider/index.ts +2 -0
  71. package/src/lib/slider/slider.component.spec.ts +55 -0
  72. package/src/lib/slider/slider.component.ts +141 -0
  73. package/src/lib/slider/slider.variants.ts +25 -0
  74. package/src/lib/switch/index.ts +2 -0
  75. package/src/lib/switch/switch.component.spec.ts +50 -0
  76. package/src/lib/switch/switch.component.ts +43 -0
  77. package/src/lib/switch/switch.variants.ts +31 -0
  78. package/src/lib/table/index.ts +12 -0
  79. package/src/lib/table/table.directives.spec.ts +111 -0
  80. package/src/lib/table/table.directives.ts +134 -0
  81. package/src/lib/table/table.variants.ts +36 -0
  82. package/src/lib/tabs/index.ts +8 -0
  83. package/src/lib/tabs/tabs.directives.spec.ts +66 -0
  84. package/src/lib/tabs/tabs.directives.ts +91 -0
  85. package/src/lib/tabs/tabs.variants.ts +17 -0
  86. package/src/lib/toast/index.ts +3 -0
  87. package/src/lib/toast/toast.service.spec.ts +71 -0
  88. package/src/lib/toast/toast.service.ts +60 -0
  89. package/src/lib/toast/toast.variants.ts +38 -0
  90. package/src/lib/toast/toaster.component.ts +80 -0
  91. package/src/lib/toggle/index.ts +2 -0
  92. package/src/lib/toggle/toggle.directive.spec.ts +52 -0
  93. package/src/lib/toggle/toggle.directive.ts +27 -0
  94. package/src/lib/toggle/toggle.variants.ts +25 -0
package/README.md CHANGED
@@ -43,7 +43,7 @@ export const appConfig = {
43
43
  And import the theme in your global styles:
44
44
 
45
45
  ```css
46
- @import '@sonny-ui/core/src/styles/sonny-theme.css';
46
+ @import '@sonny-ui/core/styles/sonny-theme.css';
47
47
  ```
48
48
 
49
49
  ### Requirements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sonny-ui/core",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.8",
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",
@@ -37,9 +37,10 @@
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
  }
@@ -0,0 +1,95 @@
1
+ import { Component, signal } 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
+ });
@@ -0,0 +1,104 @@
1
+ import { Directive, 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
+ providers: [{ provide: SNY_ACCORDION, useExisting: SnyAccordionDirective }],
11
+ host: { '[class]': 'computedClass()' },
12
+ })
13
+ export class SnyAccordionDirective {
14
+ readonly multi = input(false);
15
+ readonly class = input<string>('');
16
+
17
+ private readonly _openItems = signal(new Set<string>());
18
+
19
+ protected readonly computedClass = computed(() =>
20
+ cn('divide-y divide-border', this.class())
21
+ );
22
+
23
+ isOpen(id: string): boolean {
24
+ return this._openItems().has(id);
25
+ }
26
+
27
+ toggle(id: string): void {
28
+ this._openItems.update(set => {
29
+ const next = new Set(set);
30
+ if (next.has(id)) {
31
+ next.delete(id);
32
+ } else {
33
+ if (!this.multi()) next.clear();
34
+ next.add(id);
35
+ }
36
+ return next;
37
+ });
38
+ }
39
+ }
40
+
41
+ @Directive({
42
+ selector: '[snyAccordionItem]',
43
+ standalone: true,
44
+ providers: [{ provide: SNY_ACCORDION_ITEM, useExisting: SnyAccordionItemDirective }],
45
+ host: { '[class]': 'computedClass()' },
46
+ })
47
+ export class SnyAccordionItemDirective {
48
+ readonly value = input.required<string>();
49
+ readonly class = input<string>('');
50
+ private readonly accordion = inject(SNY_ACCORDION);
51
+
52
+ readonly isOpen = computed(() => this.accordion.isOpen(this.value()));
53
+
54
+ protected readonly computedClass = computed(() =>
55
+ cn('', this.class())
56
+ );
57
+
58
+ toggle(): void {
59
+ this.accordion.toggle(this.value());
60
+ }
61
+ }
62
+
63
+ @Directive({
64
+ selector: '[snyAccordionTrigger]',
65
+ standalone: true,
66
+ host: {
67
+ '[class]': 'computedClass()',
68
+ '[attr.aria-expanded]': 'item.isOpen()',
69
+ '(click)': 'item.toggle()',
70
+ },
71
+ })
72
+ export class SnyAccordionTriggerDirective {
73
+ readonly class = input<string>('');
74
+ readonly item = inject(SNY_ACCORDION_ITEM);
75
+
76
+ protected readonly computedClass = computed(() =>
77
+ cn(
78
+ 'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline cursor-pointer [&>svg]:transition-transform',
79
+ this.item.isOpen() && '[&>svg]:rotate-180',
80
+ this.class()
81
+ )
82
+ );
83
+ }
84
+
85
+ @Directive({
86
+ selector: '[snyAccordionContent]',
87
+ standalone: true,
88
+ host: {
89
+ '[class]': 'computedClass()',
90
+ role: 'region',
91
+ },
92
+ })
93
+ export class SnyAccordionContentDirective {
94
+ readonly class = input<string>('');
95
+ readonly item = inject(SNY_ACCORDION_ITEM);
96
+
97
+ protected readonly computedClass = computed(() =>
98
+ cn(
99
+ 'grid transition-all duration-200',
100
+ this.item.isOpen() ? 'grid-rows-[1fr] opacity-100 pb-4' : 'grid-rows-[0fr] opacity-0 overflow-hidden',
101
+ this.class()
102
+ )
103
+ );
104
+ }
@@ -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,75 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnyAvatarComponent } from './avatar.component';
4
+ import type { AvatarSize, AvatarVariant } from './avatar.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyAvatarComponent],
9
+ template: `<sny-avatar [src]="src()" [alt]="alt()" [fallback]="fallback()" [size]="size()" [variant]="variant()" />`,
10
+ })
11
+ class TestHostComponent {
12
+ src = signal('');
13
+ alt = signal('John Doe');
14
+ fallback = signal('');
15
+ size = signal<AvatarSize>('md');
16
+ variant = signal<AvatarVariant>('circle');
17
+ }
18
+
19
+ describe('SnyAvatarComponent', () => {
20
+ let fixture: ComponentFixture<TestHostComponent>;
21
+ let el: HTMLElement;
22
+
23
+ beforeEach(async () => {
24
+ await TestBed.configureTestingModule({
25
+ imports: [TestHostComponent],
26
+ }).compileComponents();
27
+
28
+ fixture = TestBed.createComponent(TestHostComponent);
29
+ fixture.detectChanges();
30
+ el = fixture.nativeElement.querySelector('sny-avatar');
31
+ });
32
+
33
+ it('should show fallback text when no src', () => {
34
+ const span = el.querySelector('span');
35
+ expect(span?.textContent?.trim()).toBe('JD');
36
+ });
37
+
38
+ it('should show custom fallback', () => {
39
+ fixture.componentInstance.fallback.set('AB');
40
+ fixture.detectChanges();
41
+ const span = el.querySelector('span');
42
+ expect(span?.textContent?.trim()).toBe('AB');
43
+ });
44
+
45
+ it('should show image when src is provided', () => {
46
+ fixture.componentInstance.src.set('https://example.com/avatar.png');
47
+ fixture.detectChanges();
48
+ const img = el.querySelector('img');
49
+ expect(img).toBeTruthy();
50
+ expect(img?.getAttribute('src')).toBe('https://example.com/avatar.png');
51
+ });
52
+
53
+ it('should apply circle variant by default', () => {
54
+ expect(el.className).toContain('rounded-full');
55
+ });
56
+
57
+ it('should apply rounded variant', () => {
58
+ fixture.componentInstance.variant.set('rounded');
59
+ fixture.detectChanges();
60
+ expect(el.className).toContain('rounded-md');
61
+ });
62
+
63
+ it('should apply size classes', () => {
64
+ fixture.componentInstance.size.set('lg');
65
+ fixture.detectChanges();
66
+ expect(el.className).toContain('h-12');
67
+ expect(el.className).toContain('w-12');
68
+ });
69
+
70
+ it('should apply xl size', () => {
71
+ fixture.componentInstance.size.set('xl');
72
+ fixture.detectChanges();
73
+ expect(el.className).toContain('h-16');
74
+ });
75
+ });
@@ -0,0 +1,43 @@
1
+ import { Component, computed, input, signal } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { avatarVariants, type AvatarSize, type AvatarVariant } from './avatar.variants';
4
+
5
+ @Component({
6
+ selector: 'sny-avatar',
7
+ standalone: true,
8
+ host: { '[class]': 'computedClass()' },
9
+ template: `
10
+ @if (src() && !error()) {
11
+ <img
12
+ [src]="src()"
13
+ [alt]="alt()"
14
+ class="aspect-square h-full w-full object-cover"
15
+ (error)="error.set(true)"
16
+ />
17
+ } @else {
18
+ <span class="font-medium text-muted-foreground">{{ fallbackText() }}</span>
19
+ }
20
+ `,
21
+ })
22
+ export class SnyAvatarComponent {
23
+ readonly src = input<string>('');
24
+ readonly alt = input<string>('');
25
+ readonly fallback = input<string>('');
26
+ readonly size = input<AvatarSize>('md');
27
+ readonly variant = input<AvatarVariant>('circle');
28
+ readonly class = input<string>('');
29
+
30
+ readonly error = signal(false);
31
+
32
+ protected readonly fallbackText = computed(() => {
33
+ const fb = this.fallback();
34
+ if (fb) return fb;
35
+ const a = this.alt();
36
+ if (a) return a.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
37
+ return '?';
38
+ });
39
+
40
+ protected readonly computedClass = computed(() =>
41
+ cn(avatarVariants({ size: this.size(), variant: this.variant() }), this.class())
42
+ );
43
+ }
@@ -0,0 +1,26 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const avatarVariants = cva(
4
+ 'relative inline-flex shrink-0 items-center justify-center overflow-hidden bg-muted',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: 'h-8 w-8 text-xs',
9
+ md: 'h-10 w-10 text-sm',
10
+ lg: 'h-12 w-12 text-base',
11
+ xl: 'h-16 w-16 text-lg',
12
+ },
13
+ variant: {
14
+ circle: 'rounded-full',
15
+ rounded: 'rounded-md',
16
+ },
17
+ },
18
+ defaultVariants: {
19
+ size: 'md',
20
+ variant: 'circle',
21
+ },
22
+ }
23
+ );
24
+
25
+ export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl';
26
+ export type AvatarVariant = 'circle' | 'rounded';
@@ -0,0 +1,2 @@
1
+ export { SnyAvatarComponent } from './avatar.component';
2
+ export { avatarVariants, type AvatarSize, type AvatarVariant } from './avatar.variants';
@@ -0,0 +1,74 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnyBadgeDirective } from './badge.directive';
4
+ import type { BadgeVariant, BadgeSize } from './badge.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyBadgeDirective],
9
+ template: `<span snyBadge [variant]="variant()" [size]="size()">Badge</span>`,
10
+ })
11
+ class TestHostComponent {
12
+ variant = signal<BadgeVariant>('default');
13
+ size = signal<BadgeSize>('md');
14
+ }
15
+
16
+ describe('SnyBadgeDirective', () => {
17
+ let fixture: ComponentFixture<TestHostComponent>;
18
+ let el: HTMLElement;
19
+
20
+ beforeEach(async () => {
21
+ await TestBed.configureTestingModule({
22
+ imports: [TestHostComponent],
23
+ }).compileComponents();
24
+
25
+ fixture = TestBed.createComponent(TestHostComponent);
26
+ fixture.detectChanges();
27
+ el = fixture.nativeElement.querySelector('[snyBadge]');
28
+ });
29
+
30
+ it('should apply default variant classes', () => {
31
+ expect(el.className).toContain('bg-primary');
32
+ expect(el.className).toContain('text-primary-foreground');
33
+ });
34
+
35
+ it('should apply destructive variant', () => {
36
+ fixture.componentInstance.variant.set('destructive');
37
+ fixture.detectChanges();
38
+ expect(el.className).toContain('bg-destructive');
39
+ });
40
+
41
+ it('should apply outline variant', () => {
42
+ fixture.componentInstance.variant.set('outline');
43
+ fixture.detectChanges();
44
+ expect(el.className).toContain('border-border');
45
+ });
46
+
47
+ it('should apply success variant', () => {
48
+ fixture.componentInstance.variant.set('success');
49
+ fixture.detectChanges();
50
+ expect(el.className).toContain('bg-green-600');
51
+ });
52
+
53
+ it('should apply warning variant', () => {
54
+ fixture.componentInstance.variant.set('warning');
55
+ fixture.detectChanges();
56
+ expect(el.className).toContain('bg-yellow-500');
57
+ });
58
+
59
+ it('should apply sm size', () => {
60
+ fixture.componentInstance.size.set('sm');
61
+ fixture.detectChanges();
62
+ expect(el.className).toContain('text-[10px]');
63
+ });
64
+
65
+ it('should apply lg size', () => {
66
+ fixture.componentInstance.size.set('lg');
67
+ fixture.detectChanges();
68
+ expect(el.className).toContain('text-sm');
69
+ });
70
+
71
+ it('should have rounded-full class', () => {
72
+ expect(el.className).toContain('rounded-full');
73
+ });
74
+ });
@@ -0,0 +1,18 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { badgeVariants, type BadgeVariant, type BadgeSize } from './badge.variants';
4
+
5
+ @Directive({
6
+ selector: '[snyBadge]',
7
+ standalone: true,
8
+ host: { '[class]': 'computedClass()' },
9
+ })
10
+ export class SnyBadgeDirective {
11
+ readonly variant = input<BadgeVariant>('default');
12
+ readonly size = input<BadgeSize>('md');
13
+ readonly class = input<string>('');
14
+
15
+ protected readonly computedClass = computed(() =>
16
+ cn(badgeVariants({ variant: this.variant(), size: this.size() }), this.class())
17
+ );
18
+ }
@@ -0,0 +1,29 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const badgeVariants = cva(
4
+ 'inline-flex items-center rounded-full border font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default: 'border-transparent bg-primary text-primary-foreground',
9
+ secondary: 'border-transparent bg-secondary text-secondary-foreground',
10
+ outline: 'border-border text-foreground',
11
+ destructive: 'border-transparent bg-destructive text-destructive-foreground',
12
+ success: 'border-transparent bg-green-600 text-white dark:bg-green-500',
13
+ warning: 'border-transparent bg-yellow-500 text-white dark:bg-yellow-400 dark:text-black',
14
+ },
15
+ size: {
16
+ sm: 'px-2 py-0.5 text-[10px]',
17
+ md: 'px-2.5 py-0.5 text-xs',
18
+ lg: 'px-3 py-1 text-sm',
19
+ },
20
+ },
21
+ defaultVariants: {
22
+ variant: 'default',
23
+ size: 'md',
24
+ },
25
+ }
26
+ );
27
+
28
+ export type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive' | 'success' | 'warning';
29
+ export type BadgeSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,2 @@
1
+ export { SnyBadgeDirective } from './badge.directive';
2
+ export { badgeVariants, type BadgeVariant, type BadgeSize } from './badge.variants';
@@ -0,0 +1,80 @@
1
+ import { Component } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import {
4
+ SnyBreadcrumbDirective,
5
+ SnyBreadcrumbListDirective,
6
+ SnyBreadcrumbItemDirective,
7
+ SnyBreadcrumbLinkDirective,
8
+ SnyBreadcrumbSeparatorDirective,
9
+ SnyBreadcrumbPageDirective,
10
+ } from './breadcrumb.directives';
11
+
12
+ @Component({
13
+ standalone: true,
14
+ imports: [
15
+ SnyBreadcrumbDirective,
16
+ SnyBreadcrumbListDirective,
17
+ SnyBreadcrumbItemDirective,
18
+ SnyBreadcrumbLinkDirective,
19
+ SnyBreadcrumbSeparatorDirective,
20
+ SnyBreadcrumbPageDirective,
21
+ ],
22
+ template: `
23
+ <nav snyBreadcrumb>
24
+ <ol snyBreadcrumbList>
25
+ <li snyBreadcrumbItem>
26
+ <a snyBreadcrumbLink href="/">Home</a>
27
+ </li>
28
+ <li snyBreadcrumbSeparator>/</li>
29
+ <li snyBreadcrumbItem>
30
+ <span snyBreadcrumbPage>Current</span>
31
+ </li>
32
+ </ol>
33
+ </nav>
34
+ `,
35
+ })
36
+ class TestHostComponent {}
37
+
38
+ describe('Breadcrumb Directives', () => {
39
+ let fixture: ComponentFixture<TestHostComponent>;
40
+
41
+ beforeEach(async () => {
42
+ await TestBed.configureTestingModule({
43
+ imports: [TestHostComponent],
44
+ }).compileComponents();
45
+
46
+ fixture = TestBed.createComponent(TestHostComponent);
47
+ fixture.detectChanges();
48
+ });
49
+
50
+ it('should set aria-label on nav', () => {
51
+ const nav = fixture.nativeElement.querySelector('nav');
52
+ expect(nav.getAttribute('aria-label')).toBe('Breadcrumb');
53
+ });
54
+
55
+ it('should apply list classes', () => {
56
+ const ol = fixture.nativeElement.querySelector('ol');
57
+ expect(ol.className).toContain('flex');
58
+ expect(ol.className).toContain('items-center');
59
+ });
60
+
61
+ it('should apply link hover classes', () => {
62
+ const link = fixture.nativeElement.querySelector('[snyBreadcrumbLink]');
63
+ expect(link.className).toContain('hover:text-foreground');
64
+ });
65
+
66
+ it('should set aria-current="page" on page', () => {
67
+ const page = fixture.nativeElement.querySelector('[snyBreadcrumbPage]');
68
+ expect(page.getAttribute('aria-current')).toBe('page');
69
+ });
70
+
71
+ it('should set aria-hidden on separator', () => {
72
+ const sep = fixture.nativeElement.querySelector('[snyBreadcrumbSeparator]');
73
+ expect(sep.getAttribute('aria-hidden')).toBe('true');
74
+ });
75
+
76
+ it('should set role="presentation" on separator', () => {
77
+ const sep = fixture.nativeElement.querySelector('[snyBreadcrumbSeparator]');
78
+ expect(sep.getAttribute('role')).toBe('presentation');
79
+ });
80
+ });