@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.
- package/README.md +101 -32
- package/fesm2022/sonny-ui-core.mjs +3031 -42
- package/fesm2022/sonny-ui-core.mjs.map +1 -1
- package/package.json +8 -5
- package/schematics/ng-add/schema.json +1 -1
- package/schematics/ng-generate/component/index.js +1 -1
- package/schematics/ng-generate/component/schema.json +1 -1
- package/src/lib/accordion/accordion.directives.spec.ts +173 -0
- package/src/lib/accordion/accordion.directives.ts +147 -0
- package/src/lib/accordion/index.ts +8 -0
- package/src/lib/alert/alert.directives.spec.ts +154 -0
- package/src/lib/alert/alert.directives.ts +70 -0
- package/src/lib/alert/alert.variants.ts +25 -0
- package/src/lib/alert/index.ts +6 -0
- package/src/lib/avatar/avatar.component.spec.ts +75 -0
- package/src/lib/avatar/avatar.component.ts +44 -0
- package/src/lib/avatar/avatar.variants.ts +26 -0
- package/src/lib/avatar/index.ts +2 -0
- package/src/lib/badge/badge.directive.spec.ts +74 -0
- package/src/lib/badge/badge.directive.ts +18 -0
- package/src/lib/badge/badge.variants.ts +29 -0
- package/src/lib/badge/index.ts +2 -0
- package/src/lib/breadcrumb/breadcrumb.directives.spec.ts +80 -0
- package/src/lib/breadcrumb/breadcrumb.directives.ts +84 -0
- package/src/lib/breadcrumb/index.ts +8 -0
- package/src/lib/button/button.directive.spec.ts +92 -0
- package/src/lib/button/button.directive.ts +29 -0
- package/src/lib/button/button.variants.ts +30 -0
- package/src/lib/button/index.ts +2 -0
- package/src/lib/button-group/button-group.directive.spec.ts +46 -0
- package/src/lib/button-group/button-group.directive.ts +20 -0
- package/src/lib/button-group/button-group.variants.ts +18 -0
- package/src/lib/button-group/index.ts +2 -0
- package/src/lib/calendar/calendar.component.spec.ts +105 -0
- package/src/lib/calendar/calendar.component.ts +231 -0
- package/src/lib/calendar/index.ts +1 -0
- package/src/lib/card/card.directives.spec.ts +104 -0
- package/src/lib/card/card.directives.ts +78 -0
- package/src/lib/card/card.variants.ts +28 -0
- package/src/lib/card/index.ts +9 -0
- package/src/lib/carousel/carousel.directives.spec.ts +85 -0
- package/src/lib/carousel/carousel.directives.ts +164 -0
- package/src/lib/carousel/index.ts +8 -0
- package/src/lib/chat-bubble/chat-bubble.directives.spec.ts +52 -0
- package/src/lib/chat-bubble/chat-bubble.directives.ts +102 -0
- package/src/lib/chat-bubble/index.ts +11 -0
- package/src/lib/checkbox/checkbox.directive.spec.ts +57 -0
- package/src/lib/checkbox/checkbox.directive.ts +17 -0
- package/src/lib/checkbox/checkbox.variants.ts +19 -0
- package/src/lib/checkbox/index.ts +2 -0
- package/src/lib/combobox/combobox.component.spec.ts +151 -0
- package/src/lib/combobox/combobox.component.ts +279 -0
- package/src/lib/combobox/combobox.variants.ts +19 -0
- package/src/lib/combobox/index.ts +2 -0
- package/src/lib/diff/diff.component.spec.ts +47 -0
- package/src/lib/diff/diff.component.ts +83 -0
- package/src/lib/diff/index.ts +1 -0
- package/src/lib/divider/divider.component.spec.ts +48 -0
- package/src/lib/divider/divider.component.ts +52 -0
- package/src/lib/divider/divider.variants.ts +22 -0
- package/src/lib/divider/index.ts +2 -0
- package/src/lib/dock/dock.directives.spec.ts +85 -0
- package/src/lib/dock/dock.directives.ts +83 -0
- package/src/lib/dock/index.ts +1 -0
- package/src/lib/drawer/drawer.directives.spec.ts +62 -0
- package/src/lib/drawer/drawer.directives.ts +83 -0
- package/src/lib/drawer/index.ts +8 -0
- package/src/lib/dropdown/dropdown.directives.spec.ts +106 -0
- package/src/lib/dropdown/dropdown.directives.ts +143 -0
- package/src/lib/dropdown/dropdown.variants.ts +27 -0
- package/src/lib/dropdown/index.ts +15 -0
- package/src/lib/fab/fab.directives.spec.ts +60 -0
- package/src/lib/fab/fab.directives.ts +80 -0
- package/src/lib/fab/index.ts +8 -0
- package/src/lib/fieldset/fieldset.directives.spec.ts +74 -0
- package/src/lib/fieldset/fieldset.directives.ts +52 -0
- package/src/lib/fieldset/fieldset.variants.ts +15 -0
- package/src/lib/fieldset/index.ts +6 -0
- package/src/lib/file-input/file-input.component.spec.ts +114 -0
- package/src/lib/file-input/file-input.component.ts +168 -0
- package/src/lib/file-input/file-input.variants.ts +25 -0
- package/src/lib/file-input/index.ts +6 -0
- package/src/lib/indicator/index.ts +6 -0
- package/src/lib/indicator/indicator.directives.spec.ts +64 -0
- package/src/lib/indicator/indicator.directives.ts +61 -0
- package/src/lib/input/index.ts +3 -0
- package/src/lib/input/input.directive.spec.ts +103 -0
- package/src/lib/input/input.directive.ts +26 -0
- package/src/lib/input/input.variants.ts +42 -0
- package/src/lib/input/label.directive.ts +17 -0
- package/src/lib/kbd/index.ts +2 -0
- package/src/lib/kbd/kbd.directive.spec.ts +42 -0
- package/src/lib/kbd/kbd.directive.ts +19 -0
- package/src/lib/kbd/kbd.variants.ts +19 -0
- package/src/lib/link/index.ts +2 -0
- package/src/lib/link/link.directive.spec.ts +41 -0
- package/src/lib/link/link.directive.ts +19 -0
- package/src/lib/link/link.variants.ts +20 -0
- package/src/lib/list/index.ts +8 -0
- package/src/lib/list/list.directives.spec.ts +65 -0
- package/src/lib/list/list.directives.ts +86 -0
- package/src/lib/loader/index.ts +2 -0
- package/src/lib/loader/loader.component.spec.ts +58 -0
- package/src/lib/loader/loader.component.ts +48 -0
- package/src/lib/loader/loader.variants.ts +21 -0
- package/src/lib/modal/dialog-ref.ts +19 -0
- package/src/lib/modal/dialog.directives.ts +90 -0
- package/src/lib/modal/dialog.service.spec.ts +52 -0
- package/src/lib/modal/dialog.service.ts +61 -0
- package/src/lib/modal/dialog.types.ts +16 -0
- package/src/lib/modal/index.ts +11 -0
- package/src/lib/navbar/index.ts +7 -0
- package/src/lib/navbar/navbar.directives.spec.ts +59 -0
- package/src/lib/navbar/navbar.directives.ts +61 -0
- package/src/lib/pagination/index.ts +6 -0
- package/src/lib/pagination/pagination.component.spec.ts +59 -0
- package/src/lib/pagination/pagination.component.ts +144 -0
- package/src/lib/pagination/pagination.variants.ts +31 -0
- package/src/lib/progress/index.ts +7 -0
- package/src/lib/progress/progress.component.spec.ts +117 -0
- package/src/lib/progress/progress.component.ts +65 -0
- package/src/lib/progress/progress.variants.ts +43 -0
- package/src/lib/radial-progress/index.ts +5 -0
- package/src/lib/radial-progress/radial-progress.component.spec.ts +41 -0
- package/src/lib/radial-progress/radial-progress.component.ts +71 -0
- package/src/lib/radio/index.ts +2 -0
- package/src/lib/radio/radio.directive.spec.ts +46 -0
- package/src/lib/radio/radio.directive.ts +17 -0
- package/src/lib/radio/radio.variants.ts +19 -0
- package/src/lib/rating/index.ts +2 -0
- package/src/lib/rating/rating.component.spec.ts +157 -0
- package/src/lib/rating/rating.component.ts +171 -0
- package/src/lib/rating/rating.variants.ts +20 -0
- package/src/lib/select/index.ts +2 -0
- package/src/lib/select/select.component.spec.ts +112 -0
- package/src/lib/select/select.component.ts +250 -0
- package/src/lib/select/select.variants.ts +19 -0
- package/src/lib/sheet/index.ts +10 -0
- package/src/lib/sheet/sheet-ref.ts +18 -0
- package/src/lib/sheet/sheet.component.spec.ts +67 -0
- package/src/lib/sheet/sheet.directives.ts +75 -0
- package/src/lib/sheet/sheet.service.ts +100 -0
- package/src/lib/sheet/sheet.types.ts +23 -0
- package/src/lib/skeleton/index.ts +2 -0
- package/src/lib/skeleton/skeleton.directive.spec.ts +63 -0
- package/src/lib/skeleton/skeleton.directive.ts +22 -0
- package/src/lib/skeleton/skeleton.variants.ts +27 -0
- package/src/lib/slider/index.ts +2 -0
- package/src/lib/slider/slider.component.spec.ts +104 -0
- package/src/lib/slider/slider.component.ts +188 -0
- package/src/lib/slider/slider.variants.ts +25 -0
- package/src/lib/stat/index.ts +8 -0
- package/src/lib/stat/stat.directives.spec.ts +60 -0
- package/src/lib/stat/stat.directives.ts +84 -0
- package/src/lib/status/index.ts +2 -0
- package/src/lib/status/status.directive.spec.ts +43 -0
- package/src/lib/status/status.directive.ts +38 -0
- package/src/lib/status/status.variants.ts +26 -0
- package/src/lib/steps/index.ts +8 -0
- package/src/lib/steps/steps.directives.spec.ts +52 -0
- package/src/lib/steps/steps.directives.ts +80 -0
- package/src/lib/switch/index.ts +2 -0
- package/src/lib/switch/switch.component.spec.ts +98 -0
- package/src/lib/switch/switch.component.ts +84 -0
- package/src/lib/switch/switch.variants.ts +31 -0
- package/src/lib/table/index.ts +12 -0
- package/src/lib/table/table.directives.spec.ts +111 -0
- package/src/lib/table/table.directives.ts +134 -0
- package/src/lib/table/table.variants.ts +36 -0
- package/src/lib/tabs/index.ts +8 -0
- package/src/lib/tabs/tabs.directives.spec.ts +136 -0
- package/src/lib/tabs/tabs.directives.ts +130 -0
- package/src/lib/tabs/tabs.variants.ts +17 -0
- package/src/lib/textarea/index.ts +7 -0
- package/src/lib/textarea/textarea.directive.spec.ts +84 -0
- package/src/lib/textarea/textarea.directive.ts +72 -0
- package/src/lib/textarea/textarea.variants.ts +34 -0
- package/src/lib/timeline/index.ts +11 -0
- package/src/lib/timeline/timeline.directives.spec.ts +55 -0
- package/src/lib/timeline/timeline.directives.ts +90 -0
- package/src/lib/toast/index.ts +3 -0
- package/src/lib/toast/toast.service.spec.ts +71 -0
- package/src/lib/toast/toast.service.ts +60 -0
- package/src/lib/toast/toast.variants.ts +38 -0
- package/src/lib/toast/toaster.component.spec.ts +38 -0
- package/src/lib/toast/toaster.component.ts +82 -0
- package/src/lib/toggle/index.ts +2 -0
- package/src/lib/toggle/toggle.directive.spec.ts +100 -0
- package/src/lib/toggle/toggle.directive.ts +73 -0
- package/src/lib/toggle/toggle.variants.ts +25 -0
- package/src/lib/tooltip/index.ts +2 -0
- package/src/lib/tooltip/tooltip.directive.spec.ts +113 -0
- package/src/lib/tooltip/tooltip.directive.ts +131 -0
- package/src/lib/tooltip/tooltip.variants.ts +20 -0
- package/src/lib/validator/index.ts +5 -0
- package/src/lib/validator/validator.directives.spec.ts +47 -0
- package/src/lib/validator/validator.directives.ts +52 -0
- package/types/sonny-ui-core.d.ts +878 -11
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Directive, InjectionToken, computed, contentChildren, inject, input, signal, effect, OnDestroy,
|
|
3
|
+
} from '@angular/core';
|
|
4
|
+
import { cn } from '../core/utils/cn';
|
|
5
|
+
|
|
6
|
+
export const SNY_CAROUSEL = new InjectionToken<SnyCarouselDirective>('SnyCarousel');
|
|
7
|
+
|
|
8
|
+
@Directive({
|
|
9
|
+
selector: '[snyCarouselItem]',
|
|
10
|
+
standalone: true,
|
|
11
|
+
host: {
|
|
12
|
+
'role': 'group',
|
|
13
|
+
'[attr.aria-roledescription]': '"slide"',
|
|
14
|
+
'[class]': 'computedClass()',
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
export class SnyCarouselItemDirective {
|
|
18
|
+
readonly class = input<string>('');
|
|
19
|
+
protected readonly computedClass = computed(() =>
|
|
20
|
+
cn('min-w-0 shrink-0 grow-0 basis-full pl-4', this.class())
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Directive({
|
|
25
|
+
selector: '[snyCarouselContent]',
|
|
26
|
+
standalone: true,
|
|
27
|
+
host: {
|
|
28
|
+
'[class]': 'computedClass()',
|
|
29
|
+
'[style.transform]': 'transformStyle()',
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
export class SnyCarouselContentDirective {
|
|
33
|
+
private readonly carousel = inject(SNY_CAROUSEL);
|
|
34
|
+
readonly class = input<string>('');
|
|
35
|
+
|
|
36
|
+
protected readonly computedClass = computed(() =>
|
|
37
|
+
cn('flex -ml-4 transition-transform duration-300 ease-in-out', this.class())
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
protected readonly transformStyle = computed(() =>
|
|
41
|
+
`translateX(-${this.carousel.currentIndex() * 100}%)`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@Directive({
|
|
46
|
+
selector: '[snyCarousel]',
|
|
47
|
+
standalone: true,
|
|
48
|
+
exportAs: 'snyCarousel',
|
|
49
|
+
providers: [{ provide: SNY_CAROUSEL, useExisting: SnyCarouselDirective }],
|
|
50
|
+
host: {
|
|
51
|
+
'role': 'region',
|
|
52
|
+
'[attr.aria-roledescription]': '"carousel"',
|
|
53
|
+
'aria-label': 'Carousel',
|
|
54
|
+
'tabindex': '0',
|
|
55
|
+
'[class]': 'computedClass()',
|
|
56
|
+
'(keydown)': 'onKeydown($event)',
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
export class SnyCarouselDirective implements OnDestroy {
|
|
60
|
+
readonly orientation = input<'horizontal' | 'vertical'>('horizontal');
|
|
61
|
+
readonly loop = input(false);
|
|
62
|
+
readonly autoplay = input(0);
|
|
63
|
+
readonly class = input<string>('');
|
|
64
|
+
|
|
65
|
+
readonly items = contentChildren(SnyCarouselItemDirective, { descendants: true });
|
|
66
|
+
readonly currentIndex = signal(0);
|
|
67
|
+
readonly totalItems = computed(() => this.items().length);
|
|
68
|
+
|
|
69
|
+
private autoplayInterval: ReturnType<typeof setInterval> | null = null;
|
|
70
|
+
|
|
71
|
+
constructor() {
|
|
72
|
+
effect(() => {
|
|
73
|
+
const ms = this.autoplay();
|
|
74
|
+
this.clearAutoplay();
|
|
75
|
+
if (ms > 0) {
|
|
76
|
+
this.autoplayInterval = setInterval(() => this.next(), ms);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
next(): void {
|
|
82
|
+
const total = this.totalItems();
|
|
83
|
+
if (total === 0) return;
|
|
84
|
+
this.currentIndex.update((i) => {
|
|
85
|
+
if (i >= total - 1) return this.loop() ? 0 : i;
|
|
86
|
+
return i + 1;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
prev(): void {
|
|
91
|
+
const total = this.totalItems();
|
|
92
|
+
if (total === 0) return;
|
|
93
|
+
this.currentIndex.update((i) => {
|
|
94
|
+
if (i <= 0) return this.loop() ? total - 1 : i;
|
|
95
|
+
return i - 1;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
goTo(index: number): void {
|
|
100
|
+
this.currentIndex.set(Math.max(0, Math.min(index, this.totalItems() - 1)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onKeydown(event: KeyboardEvent): void {
|
|
104
|
+
switch (event.key) {
|
|
105
|
+
case 'ArrowLeft':
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
this.prev();
|
|
108
|
+
break;
|
|
109
|
+
case 'ArrowRight':
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
this.next();
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ngOnDestroy(): void {
|
|
117
|
+
this.clearAutoplay();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private clearAutoplay(): void {
|
|
121
|
+
if (this.autoplayInterval) {
|
|
122
|
+
clearInterval(this.autoplayInterval);
|
|
123
|
+
this.autoplayInterval = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected readonly computedClass = computed(() =>
|
|
128
|
+
cn('relative overflow-hidden', this.class())
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@Directive({
|
|
133
|
+
selector: '[snyCarouselPrev]',
|
|
134
|
+
standalone: true,
|
|
135
|
+
host: {
|
|
136
|
+
'(click)': 'carousel.prev()',
|
|
137
|
+
'[attr.aria-label]': '"Previous slide"',
|
|
138
|
+
'[class]': 'computedClass()',
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
export class SnyCarouselPrevDirective {
|
|
142
|
+
readonly carousel = inject(SNY_CAROUSEL);
|
|
143
|
+
readonly class = input<string>('');
|
|
144
|
+
protected readonly computedClass = computed(() =>
|
|
145
|
+
cn('absolute left-2 top-1/2 -translate-y-1/2 z-10', this.class())
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@Directive({
|
|
150
|
+
selector: '[snyCarouselNext]',
|
|
151
|
+
standalone: true,
|
|
152
|
+
host: {
|
|
153
|
+
'(click)': 'carousel.next()',
|
|
154
|
+
'[attr.aria-label]': '"Next slide"',
|
|
155
|
+
'[class]': 'computedClass()',
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
export class SnyCarouselNextDirective {
|
|
159
|
+
readonly carousel = inject(SNY_CAROUSEL);
|
|
160
|
+
readonly class = input<string>('');
|
|
161
|
+
protected readonly computedClass = computed(() =>
|
|
162
|
+
cn('absolute right-2 top-1/2 -translate-y-1/2 z-10', this.class())
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyChatBubbleDirective, SnyChatBubbleContentDirective, type ChatBubbleAlign, type ChatBubbleContentVariant } from './chat-bubble.directives';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [SnyChatBubbleDirective, SnyChatBubbleContentDirective],
|
|
8
|
+
template: `
|
|
9
|
+
<div snyChatBubble [align]="align()">
|
|
10
|
+
<div snyChatBubbleContent [variant]="variant()">Hello!</div>
|
|
11
|
+
</div>
|
|
12
|
+
`,
|
|
13
|
+
})
|
|
14
|
+
class TestHostComponent {
|
|
15
|
+
align = signal<ChatBubbleAlign>('start');
|
|
16
|
+
variant = signal<ChatBubbleContentVariant>('default');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('SnyChatBubbleDirective', () => {
|
|
20
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
|
|
24
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
25
|
+
fixture.detectChanges();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should render with article role', () => {
|
|
29
|
+
const bubble = fixture.nativeElement.querySelector('[snyChatBubble]');
|
|
30
|
+
expect(bubble.getAttribute('role')).toBe('article');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should apply start alignment by default', () => {
|
|
34
|
+
const bubble = fixture.nativeElement.querySelector('[snyChatBubble]');
|
|
35
|
+
expect(bubble.className).toContain('flex');
|
|
36
|
+
expect(bubble.className).not.toContain('flex-row-reverse');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should apply end alignment', () => {
|
|
40
|
+
fixture.componentInstance.align.set('end');
|
|
41
|
+
fixture.detectChanges();
|
|
42
|
+
const bubble = fixture.nativeElement.querySelector('[snyChatBubble]');
|
|
43
|
+
expect(bubble.className).toContain('flex-row-reverse');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should apply primary content variant', () => {
|
|
47
|
+
fixture.componentInstance.variant.set('primary');
|
|
48
|
+
fixture.detectChanges();
|
|
49
|
+
const content = fixture.nativeElement.querySelector('[snyChatBubbleContent]');
|
|
50
|
+
expect(content.className).toContain('bg-primary');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Directive, InjectionToken, computed, inject, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
|
|
4
|
+
export type ChatBubbleAlign = 'start' | 'end';
|
|
5
|
+
export type ChatBubbleContentVariant = 'default' | 'primary' | 'secondary' | 'accent';
|
|
6
|
+
|
|
7
|
+
export const SNY_CHAT_BUBBLE = new InjectionToken<SnyChatBubbleDirective>('SnyChatBubble');
|
|
8
|
+
|
|
9
|
+
@Directive({
|
|
10
|
+
selector: '[snyChatBubble]',
|
|
11
|
+
standalone: true,
|
|
12
|
+
providers: [{ provide: SNY_CHAT_BUBBLE, useExisting: SnyChatBubbleDirective }],
|
|
13
|
+
host: {
|
|
14
|
+
'role': 'article',
|
|
15
|
+
'[class]': 'computedClass()',
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
export class SnyChatBubbleDirective {
|
|
19
|
+
readonly align = input<ChatBubbleAlign>('start');
|
|
20
|
+
readonly class = input<string>('');
|
|
21
|
+
|
|
22
|
+
protected readonly computedClass = computed(() =>
|
|
23
|
+
cn(
|
|
24
|
+
'flex gap-3 mb-4',
|
|
25
|
+
this.align() === 'end' && 'flex-row-reverse',
|
|
26
|
+
this.class()
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Directive({
|
|
32
|
+
selector: '[snyChatBubbleAvatar]',
|
|
33
|
+
standalone: true,
|
|
34
|
+
host: { '[class]': 'computedClass()' },
|
|
35
|
+
})
|
|
36
|
+
export class SnyChatBubbleAvatarDirective {
|
|
37
|
+
readonly class = input<string>('');
|
|
38
|
+
protected readonly computedClass = computed(() =>
|
|
39
|
+
cn('flex-shrink-0 w-10 h-10 rounded-full overflow-hidden', this.class())
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Directive({
|
|
44
|
+
selector: '[snyChatBubbleHeader]',
|
|
45
|
+
standalone: true,
|
|
46
|
+
host: { '[class]': 'computedClass()' },
|
|
47
|
+
})
|
|
48
|
+
export class SnyChatBubbleHeaderDirective {
|
|
49
|
+
readonly class = input<string>('');
|
|
50
|
+
protected readonly computedClass = computed(() =>
|
|
51
|
+
cn('text-xs text-muted-foreground mb-1', this.class())
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Directive({
|
|
56
|
+
selector: '[snyChatBubbleContent]',
|
|
57
|
+
standalone: true,
|
|
58
|
+
host: { '[class]': 'computedClass()' },
|
|
59
|
+
})
|
|
60
|
+
export class SnyChatBubbleContentDirective {
|
|
61
|
+
readonly variant = input<ChatBubbleContentVariant>('default');
|
|
62
|
+
readonly class = input<string>('');
|
|
63
|
+
|
|
64
|
+
protected readonly computedClass = computed(() => {
|
|
65
|
+
const v = this.variant();
|
|
66
|
+
const variantClass =
|
|
67
|
+
v === 'primary' ? 'bg-primary text-primary-foreground' :
|
|
68
|
+
v === 'secondary' ? 'bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100' :
|
|
69
|
+
v === 'accent' ? 'bg-violet-100 text-violet-900 dark:bg-violet-900 dark:text-violet-100' :
|
|
70
|
+
'bg-muted';
|
|
71
|
+
return cn('rounded-lg px-3 py-2 text-sm max-w-[80%]', variantClass, this.class());
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Directive({
|
|
76
|
+
selector: '[snyChatBubbleFooter]',
|
|
77
|
+
standalone: true,
|
|
78
|
+
host: { '[class]': 'computedClass()' },
|
|
79
|
+
})
|
|
80
|
+
export class SnyChatBubbleFooterDirective {
|
|
81
|
+
readonly class = input<string>('');
|
|
82
|
+
protected readonly computedClass = computed(() =>
|
|
83
|
+
cn('text-xs text-muted-foreground mt-1', this.class())
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Directive({
|
|
88
|
+
selector: '[snyChatBubbleBody]',
|
|
89
|
+
standalone: true,
|
|
90
|
+
host: { '[class]': 'computedClass()' },
|
|
91
|
+
})
|
|
92
|
+
export class SnyChatBubbleBodyDirective {
|
|
93
|
+
private readonly chatBubble = inject(SNY_CHAT_BUBBLE);
|
|
94
|
+
readonly class = input<string>('');
|
|
95
|
+
protected readonly computedClass = computed(() =>
|
|
96
|
+
cn(
|
|
97
|
+
'flex flex-col',
|
|
98
|
+
this.chatBubble.align() === 'end' && 'items-end',
|
|
99
|
+
this.class()
|
|
100
|
+
)
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SnyChatBubbleDirective,
|
|
3
|
+
SnyChatBubbleAvatarDirective,
|
|
4
|
+
SnyChatBubbleHeaderDirective,
|
|
5
|
+
SnyChatBubbleContentDirective,
|
|
6
|
+
SnyChatBubbleFooterDirective,
|
|
7
|
+
SnyChatBubbleBodyDirective,
|
|
8
|
+
SNY_CHAT_BUBBLE,
|
|
9
|
+
type ChatBubbleAlign,
|
|
10
|
+
type ChatBubbleContentVariant,
|
|
11
|
+
} from './chat-bubble.directives';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyCheckboxDirective } from './checkbox.directive';
|
|
4
|
+
import type { CheckboxSize } from './checkbox.variants';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyCheckboxDirective],
|
|
9
|
+
template: `<input type="checkbox" snyCheckbox [size]="size()" />`,
|
|
10
|
+
})
|
|
11
|
+
class TestHostComponent {
|
|
12
|
+
size = signal<CheckboxSize>('md');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('SnyCheckboxDirective', () => {
|
|
16
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
17
|
+
let el: HTMLInputElement;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
await TestBed.configureTestingModule({
|
|
21
|
+
imports: [TestHostComponent],
|
|
22
|
+
}).compileComponents();
|
|
23
|
+
|
|
24
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
25
|
+
fixture.detectChanges();
|
|
26
|
+
el = fixture.nativeElement.querySelector('input[type="checkbox"]');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should apply default classes', () => {
|
|
30
|
+
expect(el.className).toContain('appearance-none');
|
|
31
|
+
expect(el.className).toContain('rounded-sm');
|
|
32
|
+
expect(el.className).toContain('border');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should apply default md size', () => {
|
|
36
|
+
expect(el.className).toContain('h-4');
|
|
37
|
+
expect(el.className).toContain('w-4');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should apply sm size', () => {
|
|
41
|
+
fixture.componentInstance.size.set('sm');
|
|
42
|
+
fixture.detectChanges();
|
|
43
|
+
expect(el.className).toContain('h-3.5');
|
|
44
|
+
expect(el.className).toContain('w-3.5');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should apply lg size', () => {
|
|
48
|
+
fixture.componentInstance.size.set('lg');
|
|
49
|
+
fixture.detectChanges();
|
|
50
|
+
expect(el.className).toContain('h-5');
|
|
51
|
+
expect(el.className).toContain('w-5');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should have checked styles in class list', () => {
|
|
55
|
+
expect(el.className).toContain('checked:bg-primary');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Directive, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import { checkboxVariants, type CheckboxSize } from './checkbox.variants';
|
|
4
|
+
|
|
5
|
+
@Directive({
|
|
6
|
+
selector: 'input[type="checkbox"][snyCheckbox]',
|
|
7
|
+
standalone: true,
|
|
8
|
+
host: { '[class]': 'computedClass()' },
|
|
9
|
+
})
|
|
10
|
+
export class SnyCheckboxDirective {
|
|
11
|
+
readonly size = input<CheckboxSize>('md');
|
|
12
|
+
readonly class = input<string>('');
|
|
13
|
+
|
|
14
|
+
protected readonly computedClass = computed(() =>
|
|
15
|
+
cn(checkboxVariants({ size: this.size() }), this.class())
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const checkboxVariants = cva(
|
|
4
|
+
'peer appearance-none shrink-0 rounded-sm border border-border bg-background transition-colors checked:bg-primary checked:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: 'h-3.5 w-3.5',
|
|
9
|
+
md: 'h-4 w-4',
|
|
10
|
+
lg: 'h-5 w-5',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: {
|
|
14
|
+
size: 'md',
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export type CheckboxSize = 'sm' | 'md' | 'lg';
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
|
3
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
4
|
+
import { SnyComboboxComponent, type ComboboxOption } from './combobox.component';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyComboboxComponent],
|
|
9
|
+
template: `<sny-combobox [options]="options" [(value)]="value" placeholder="Select..." searchPlaceholder="Search..." />`,
|
|
10
|
+
})
|
|
11
|
+
class TestHostComponent {
|
|
12
|
+
value = signal('');
|
|
13
|
+
options: ComboboxOption[] = [
|
|
14
|
+
{ value: 'us', label: 'United States' },
|
|
15
|
+
{ value: 'uk', label: 'United Kingdom' },
|
|
16
|
+
{ value: 'ca', label: 'Canada' },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('SnyComboboxComponent', () => {
|
|
21
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
22
|
+
let el: HTMLElement;
|
|
23
|
+
let trigger: HTMLButtonElement;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
await TestBed.configureTestingModule({
|
|
27
|
+
imports: [TestHostComponent],
|
|
28
|
+
}).compileComponents();
|
|
29
|
+
|
|
30
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
31
|
+
fixture.detectChanges();
|
|
32
|
+
el = fixture.nativeElement.querySelector('sny-combobox');
|
|
33
|
+
trigger = el.querySelector('button')!;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should render the trigger button', () => {
|
|
37
|
+
expect(trigger).toBeTruthy();
|
|
38
|
+
expect(trigger.getAttribute('role')).toBe('combobox');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should show placeholder when no value selected', () => {
|
|
42
|
+
expect(trigger.textContent).toContain('Select...');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should set aria-expanded=false initially', () => {
|
|
46
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should open dropdown on trigger click', () => {
|
|
50
|
+
trigger.click();
|
|
51
|
+
fixture.detectChanges();
|
|
52
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
|
53
|
+
const listbox = el.querySelector('[role="listbox"]');
|
|
54
|
+
expect(listbox).toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should show search input in dropdown', () => {
|
|
58
|
+
trigger.click();
|
|
59
|
+
fixture.detectChanges();
|
|
60
|
+
const searchInput = el.querySelector('input[type="text"]') as HTMLInputElement;
|
|
61
|
+
expect(searchInput).toBeTruthy();
|
|
62
|
+
expect(searchInput.getAttribute('placeholder')).toBe('Search...');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should show all options when no query', () => {
|
|
66
|
+
trigger.click();
|
|
67
|
+
fixture.detectChanges();
|
|
68
|
+
const options = el.querySelectorAll('[role="option"]');
|
|
69
|
+
expect(options.length).toBe(3);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should select option on click and show label', () => {
|
|
73
|
+
trigger.click();
|
|
74
|
+
fixture.detectChanges();
|
|
75
|
+
|
|
76
|
+
const option = el.querySelector('[role="option"]') as HTMLElement;
|
|
77
|
+
option.dispatchEvent(new Event('mousedown'));
|
|
78
|
+
fixture.detectChanges();
|
|
79
|
+
|
|
80
|
+
expect(fixture.componentInstance.value()).toBe('us');
|
|
81
|
+
expect(trigger.textContent).toContain('United States');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should close dropdown after selecting', () => {
|
|
85
|
+
trigger.click();
|
|
86
|
+
fixture.detectChanges();
|
|
87
|
+
|
|
88
|
+
const option = el.querySelector('[role="option"]') as HTMLElement;
|
|
89
|
+
option.dispatchEvent(new Event('mousedown'));
|
|
90
|
+
fixture.detectChanges();
|
|
91
|
+
|
|
92
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
@Component({
|
|
97
|
+
standalone: true,
|
|
98
|
+
imports: [ReactiveFormsModule, SnyComboboxComponent],
|
|
99
|
+
template: `<sny-combobox [options]="options" [formControl]="ctrl" placeholder="Select..." />`,
|
|
100
|
+
})
|
|
101
|
+
class ReactiveFormHost {
|
|
102
|
+
options: ComboboxOption[] = [
|
|
103
|
+
{ value: 'us', label: 'United States' },
|
|
104
|
+
{ value: 'uk', label: 'United Kingdom' },
|
|
105
|
+
{ value: 'ca', label: 'Canada' },
|
|
106
|
+
];
|
|
107
|
+
ctrl = new FormControl('');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('SnyComboboxComponent — Reactive Forms', () => {
|
|
111
|
+
let fixture: ComponentFixture<ReactiveFormHost>;
|
|
112
|
+
let el: HTMLElement;
|
|
113
|
+
let trigger: HTMLButtonElement;
|
|
114
|
+
|
|
115
|
+
beforeEach(async () => {
|
|
116
|
+
await TestBed.configureTestingModule({
|
|
117
|
+
imports: [ReactiveFormHost],
|
|
118
|
+
}).compileComponents();
|
|
119
|
+
fixture = TestBed.createComponent(ReactiveFormHost);
|
|
120
|
+
fixture.detectChanges();
|
|
121
|
+
el = fixture.nativeElement.querySelector('sny-combobox');
|
|
122
|
+
trigger = el.querySelector('button')!;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should update view when FormControl value changes (writeValue)', () => {
|
|
126
|
+
fixture.componentInstance.ctrl.setValue('uk');
|
|
127
|
+
fixture.detectChanges();
|
|
128
|
+
expect(trigger.textContent).toContain('United Kingdom');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should update FormControl when user interacts (onChange)', () => {
|
|
132
|
+
trigger.click();
|
|
133
|
+
fixture.detectChanges();
|
|
134
|
+
const option = el.querySelector('[role="option"]') as HTMLElement;
|
|
135
|
+
option.dispatchEvent(new Event('mousedown'));
|
|
136
|
+
fixture.detectChanges();
|
|
137
|
+
expect(fixture.componentInstance.ctrl.value).toBe('us');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should handle FormControl.disable() (setDisabledState)', () => {
|
|
141
|
+
fixture.componentInstance.ctrl.disable();
|
|
142
|
+
fixture.detectChanges();
|
|
143
|
+
expect(fixture.componentInstance.ctrl.disabled).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should mark as touched on blur (onTouched)', () => {
|
|
147
|
+
expect(fixture.componentInstance.ctrl.touched).toBe(false);
|
|
148
|
+
trigger.dispatchEvent(new Event('blur'));
|
|
149
|
+
expect(fixture.componentInstance.ctrl.touched).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|