@sonny-ui/core 0.1.0-alpha.2 → 0.1.0-alpha.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -40
- package/fesm2022/sonny-ui-core.mjs +6642 -268
- package/fesm2022/sonny-ui-core.mjs.map +1 -1
- package/package.json +8 -5
- package/schematics/ng-add/index.js +27 -0
- package/schematics/ng-add/schema.json +1 -1
- package/schematics/ng-generate/component/index.js +182 -1
- package/schematics/ng-generate/component/schema.json +2 -2
- package/src/lib/accordion/accordion.directives.spec.ts +173 -0
- package/src/lib/accordion/accordion.directives.ts +143 -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 +67 -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 +43 -0
- package/src/lib/avatar/avatar.variants.ts +26 -0
- package/src/lib/avatar/index.ts +2 -0
- package/src/lib/avatar-group/avatar-group.component.spec.ts +74 -0
- package/src/lib/avatar-group/avatar-group.component.ts +88 -0
- package/src/lib/avatar-group/index.ts +1 -0
- package/src/lib/badge/badge.directive.spec.ts +74 -0
- package/src/lib/badge/badge.directive.ts +17 -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 +78 -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 +28 -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 +19 -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 +192 -0
- package/src/lib/calendar/calendar.component.ts +342 -0
- package/src/lib/calendar/calendar.types.ts +24 -0
- package/src/lib/calendar/index.ts +7 -0
- package/src/lib/card/card.directives.spec.ts +104 -0
- package/src/lib/card/card.directives.ts +72 -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 +159 -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 +96 -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 +16 -0
- package/src/lib/checkbox/checkbox.variants.ts +19 -0
- package/src/lib/checkbox/index.ts +2 -0
- package/src/lib/color-picker/color-picker.component.spec.ts +328 -0
- package/src/lib/color-picker/color-picker.component.ts +537 -0
- package/src/lib/color-picker/color-picker.types.ts +24 -0
- package/src/lib/color-picker/color-picker.utils.ts +183 -0
- package/src/lib/color-picker/color-picker.variants.ts +17 -0
- package/src/lib/color-picker/index.ts +20 -0
- package/src/lib/combobox/combobox.component.spec.ts +151 -0
- package/src/lib/combobox/combobox.component.ts +264 -0
- package/src/lib/combobox/combobox.variants.ts +19 -0
- package/src/lib/combobox/index.ts +2 -0
- package/src/lib/command-palette/command-palette.component.spec.ts +178 -0
- package/src/lib/command-palette/command-palette.component.ts +194 -0
- package/src/lib/command-palette/command-palette.service.ts +36 -0
- package/src/lib/command-palette/command-palette.types.ts +23 -0
- package/src/lib/command-palette/index.ts +7 -0
- package/src/lib/data-table/data-table.component.spec.ts +443 -0
- package/src/lib/data-table/data-table.component.ts +602 -0
- package/src/lib/data-table/data-table.directives.ts +31 -0
- package/src/lib/data-table/data-table.types.ts +20 -0
- package/src/lib/data-table/index.ts +13 -0
- package/src/lib/date-picker/date-picker.component.spec.ts +131 -0
- package/src/lib/date-picker/date-picker.component.ts +220 -0
- package/src/lib/date-picker/date-picker.variants.ts +17 -0
- package/src/lib/date-picker/index.ts +2 -0
- package/src/lib/date-range-picker/date-range-picker.component.spec.ts +151 -0
- package/src/lib/date-range-picker/date-range-picker.component.ts +340 -0
- package/src/lib/date-range-picker/index.ts +1 -0
- package/src/lib/diff/diff.component.spec.ts +47 -0
- package/src/lib/diff/diff.component.ts +82 -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 +51 -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 +81 -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 +80 -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 +136 -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 +77 -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 +49 -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 +155 -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 +59 -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 +25 -0
- package/src/lib/input/input.variants.ts +42 -0
- package/src/lib/input/label.directive.ts +16 -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 +18 -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 +18 -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 +81 -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 +47 -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 +84 -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 +57 -0
- package/src/lib/number-input/index.ts +2 -0
- package/src/lib/number-input/number-input.component.spec.ts +151 -0
- package/src/lib/number-input/number-input.component.ts +152 -0
- package/src/lib/number-input/number-input.variants.ts +17 -0
- package/src/lib/otp-input/index.ts +2 -0
- package/src/lib/otp-input/otp-input.component.spec.ts +252 -0
- package/src/lib/otp-input/otp-input.component.ts +274 -0
- package/src/lib/otp-input/otp-input.variants.ts +18 -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 +143 -0
- package/src/lib/pagination/pagination.variants.ts +31 -0
- package/src/lib/popover/index.ts +6 -0
- package/src/lib/popover/popover.directives.spec.ts +147 -0
- package/src/lib/popover/popover.directives.ts +151 -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 +64 -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 +70 -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 +16 -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 +163 -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 +235 -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 +70 -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 +21 -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 +181 -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 +79 -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 +37 -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 +78 -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 +76 -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 +126 -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 +126 -0
- package/src/lib/tabs/tabs.variants.ts +17 -0
- package/src/lib/tag-input/index.ts +2 -0
- package/src/lib/tag-input/tag-input.component.spec.ts +190 -0
- package/src/lib/tag-input/tag-input.component.ts +172 -0
- package/src/lib/tag-input/tag-input.variants.ts +31 -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 +71 -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 +85 -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 +81 -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 +61 -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 +130 -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 +50 -0
- package/src/styles/sonny-theme.css +33 -0
- package/types/sonny-ui-core.d.ts +1443 -13
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Directive,
|
|
3
|
+
computed,
|
|
4
|
+
input,
|
|
5
|
+
effect,
|
|
6
|
+
inject,
|
|
7
|
+
ElementRef,
|
|
8
|
+
afterNextRender,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
import { cn } from '../core/utils/cn';
|
|
11
|
+
import {
|
|
12
|
+
textareaVariants,
|
|
13
|
+
type TextareaVariant,
|
|
14
|
+
type TextareaSize,
|
|
15
|
+
type TextareaResize,
|
|
16
|
+
} from './textarea.variants';
|
|
17
|
+
|
|
18
|
+
@Directive({
|
|
19
|
+
selector: 'textarea[snyTextarea]',
|
|
20
|
+
host: {
|
|
21
|
+
'[class]': 'computedClass()',
|
|
22
|
+
'[attr.aria-invalid]': 'variant() === "error" || null',
|
|
23
|
+
'(input)': 'onInput()',
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
export class SnyTextareaDirective {
|
|
27
|
+
readonly variant = input<TextareaVariant>('default');
|
|
28
|
+
readonly textareaSize = input<TextareaSize>('md');
|
|
29
|
+
readonly resize = input<TextareaResize>('vertical');
|
|
30
|
+
readonly autoResize = input(false);
|
|
31
|
+
readonly class = input<string>('');
|
|
32
|
+
|
|
33
|
+
private readonly el = inject(ElementRef<HTMLTextAreaElement>);
|
|
34
|
+
|
|
35
|
+
protected readonly computedClass = computed(() =>
|
|
36
|
+
cn(
|
|
37
|
+
textareaVariants({
|
|
38
|
+
variant: this.variant(),
|
|
39
|
+
textareaSize: this.textareaSize(),
|
|
40
|
+
resize: this.autoResize() ? 'none' : this.resize(),
|
|
41
|
+
}),
|
|
42
|
+
this.class()
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
constructor() {
|
|
47
|
+
afterNextRender(() => {
|
|
48
|
+
if (this.autoResize()) {
|
|
49
|
+
this.adjustHeight();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
effect(() => {
|
|
54
|
+
if (this.autoResize()) {
|
|
55
|
+
this.adjustHeight();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
protected onInput(): void {
|
|
61
|
+
if (this.autoResize()) {
|
|
62
|
+
this.adjustHeight();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private adjustHeight(): void {
|
|
67
|
+
const textarea = this.el.nativeElement;
|
|
68
|
+
textarea.style.height = 'auto';
|
|
69
|
+
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const textareaVariants = cva(
|
|
4
|
+
'flex w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground 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
|
+
variant: {
|
|
8
|
+
default: 'border-input',
|
|
9
|
+
error:
|
|
10
|
+
'border-destructive focus-visible:ring-destructive text-destructive placeholder:text-destructive/60',
|
|
11
|
+
},
|
|
12
|
+
textareaSize: {
|
|
13
|
+
sm: 'min-h-[60px] text-xs',
|
|
14
|
+
md: 'min-h-[80px] text-sm',
|
|
15
|
+
lg: 'min-h-[120px] text-base',
|
|
16
|
+
},
|
|
17
|
+
resize: {
|
|
18
|
+
none: 'resize-none',
|
|
19
|
+
vertical: 'resize-y',
|
|
20
|
+
horizontal: 'resize-x',
|
|
21
|
+
both: 'resize',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: 'default',
|
|
26
|
+
textareaSize: 'md',
|
|
27
|
+
resize: 'vertical',
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export type TextareaVariant = 'default' | 'error';
|
|
33
|
+
export type TextareaSize = 'sm' | 'md' | 'lg';
|
|
34
|
+
export type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SnyTimelineDirective,
|
|
3
|
+
SnyTimelineItemDirective,
|
|
4
|
+
SnyTimelineStartDirective,
|
|
5
|
+
SnyTimelineMiddleDirective,
|
|
6
|
+
SnyTimelineEndDirective,
|
|
7
|
+
SNY_TIMELINE,
|
|
8
|
+
type TimelineOrientation,
|
|
9
|
+
type TimelineConnect,
|
|
10
|
+
type TimelineMiddleVariant,
|
|
11
|
+
} from './timeline.directives';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import {
|
|
4
|
+
SnyTimelineDirective,
|
|
5
|
+
SnyTimelineItemDirective,
|
|
6
|
+
SnyTimelineStartDirective,
|
|
7
|
+
SnyTimelineMiddleDirective,
|
|
8
|
+
SnyTimelineEndDirective,
|
|
9
|
+
} from './timeline.directives';
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
standalone: true,
|
|
13
|
+
imports: [SnyTimelineDirective, SnyTimelineItemDirective, SnyTimelineStartDirective, SnyTimelineMiddleDirective, SnyTimelineEndDirective],
|
|
14
|
+
template: `
|
|
15
|
+
<div snyTimeline>
|
|
16
|
+
<div snyTimelineItem>
|
|
17
|
+
<div snyTimelineStart>2024</div>
|
|
18
|
+
<div snyTimelineMiddle variant="primary"></div>
|
|
19
|
+
<div snyTimelineEnd>Event 1</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div snyTimelineItem>
|
|
22
|
+
<div snyTimelineStart>2025</div>
|
|
23
|
+
<div snyTimelineMiddle></div>
|
|
24
|
+
<div snyTimelineEnd>Event 2</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
`,
|
|
28
|
+
})
|
|
29
|
+
class TestHostComponent {}
|
|
30
|
+
|
|
31
|
+
describe('SnyTimelineDirective', () => {
|
|
32
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
|
|
36
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
37
|
+
fixture.detectChanges();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should render with list role', () => {
|
|
41
|
+
const timeline = fixture.nativeElement.querySelector('[snyTimeline]');
|
|
42
|
+
expect(timeline.getAttribute('role')).toBe('list');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should render items with listitem role', () => {
|
|
46
|
+
const items = fixture.nativeElement.querySelectorAll('[snyTimelineItem]');
|
|
47
|
+
expect(items.length).toBe(2);
|
|
48
|
+
expect(items[0].getAttribute('role')).toBe('listitem');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should mark middle as aria-hidden', () => {
|
|
52
|
+
const middles = fixture.nativeElement.querySelectorAll('[snyTimelineMiddle]');
|
|
53
|
+
expect(middles[0].getAttribute('aria-hidden')).toBe('true');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Directive, InjectionToken, computed, contentChildren, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
|
|
4
|
+
export const SNY_TIMELINE = new InjectionToken<SnyTimelineDirective>('SnyTimeline');
|
|
5
|
+
export type TimelineOrientation = 'vertical' | 'horizontal';
|
|
6
|
+
export type TimelineConnect = 'start' | 'end' | 'both' | 'none';
|
|
7
|
+
export type TimelineMiddleVariant = 'default' | 'primary' | 'success' | 'error';
|
|
8
|
+
|
|
9
|
+
@Directive({
|
|
10
|
+
selector: '[snyTimelineItem]',
|
|
11
|
+
host: { 'role': 'listitem', '[class]': 'computedClass()' },
|
|
12
|
+
})
|
|
13
|
+
export class SnyTimelineItemDirective {
|
|
14
|
+
readonly connect = input<TimelineConnect>('both');
|
|
15
|
+
readonly class = input<string>('');
|
|
16
|
+
protected readonly computedClass = computed(() =>
|
|
17
|
+
cn('relative flex gap-4', this.class())
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Directive({
|
|
22
|
+
selector: '[snyTimelineStart]',
|
|
23
|
+
host: { '[class]': 'computedClass()' },
|
|
24
|
+
})
|
|
25
|
+
export class SnyTimelineStartDirective {
|
|
26
|
+
readonly class = input<string>('');
|
|
27
|
+
protected readonly computedClass = computed(() =>
|
|
28
|
+
cn('text-sm text-muted-foreground min-w-[80px] text-right', this.class())
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@Directive({
|
|
33
|
+
selector: '[snyTimelineMiddle]',
|
|
34
|
+
host: { '[class]': 'computedClass()', 'aria-hidden': 'true' },
|
|
35
|
+
})
|
|
36
|
+
export class SnyTimelineMiddleDirective {
|
|
37
|
+
readonly variant = input<TimelineMiddleVariant>('default');
|
|
38
|
+
readonly class = input<string>('');
|
|
39
|
+
protected readonly computedClass = computed(() => {
|
|
40
|
+
const v = this.variant();
|
|
41
|
+
const variantClass =
|
|
42
|
+
v === 'primary' ? 'bg-primary' :
|
|
43
|
+
v === 'success' ? 'bg-green-600 dark:bg-green-500' :
|
|
44
|
+
v === 'error' ? 'bg-destructive' :
|
|
45
|
+
'bg-border';
|
|
46
|
+
return cn('flex flex-col items-center', variantClass, this.class());
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Directive({
|
|
51
|
+
selector: '[snyTimelineEnd]',
|
|
52
|
+
host: { '[class]': 'computedClass()' },
|
|
53
|
+
})
|
|
54
|
+
export class SnyTimelineEndDirective {
|
|
55
|
+
readonly class = input<string>('');
|
|
56
|
+
protected readonly computedClass = computed(() =>
|
|
57
|
+
cn('flex-1 pb-8', this.class())
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Directive({
|
|
62
|
+
selector: '[snyTimeline]',
|
|
63
|
+
exportAs: 'snyTimeline',
|
|
64
|
+
providers: [{ provide: SNY_TIMELINE, useExisting: SnyTimelineDirective }],
|
|
65
|
+
host: {
|
|
66
|
+
'role': 'list',
|
|
67
|
+
'aria-label': 'Timeline',
|
|
68
|
+
'[class]': 'computedClass()',
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
export class SnyTimelineDirective {
|
|
72
|
+
readonly orientation = input<TimelineOrientation>('vertical');
|
|
73
|
+
readonly class = input<string>('');
|
|
74
|
+
|
|
75
|
+
readonly items = contentChildren(SnyTimelineItemDirective);
|
|
76
|
+
|
|
77
|
+
protected readonly computedClass = computed(() => {
|
|
78
|
+
const o = this.orientation();
|
|
79
|
+
return cn(
|
|
80
|
+
'relative',
|
|
81
|
+
o === 'vertical' ? 'flex flex-col' : 'flex flex-row',
|
|
82
|
+
this.class()
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
import { SnyToastService } from './toast.service';
|
|
3
|
+
|
|
4
|
+
describe('SnyToastService', () => {
|
|
5
|
+
let service: SnyToastService;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
TestBed.configureTestingModule({});
|
|
9
|
+
service = TestBed.inject(SnyToastService);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should create a toast', () => {
|
|
13
|
+
const id = service.show({ title: 'Test', duration: 0 });
|
|
14
|
+
expect(id).toBeTruthy();
|
|
15
|
+
expect(service.toasts().length).toBe(1);
|
|
16
|
+
expect(service.toasts()[0].title).toBe('Test');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should create toast with variant', () => {
|
|
20
|
+
service.show({ title: 'Error', variant: 'destructive', duration: 0 });
|
|
21
|
+
expect(service.toasts()[0].variant).toBe('destructive');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should dismiss a toast', () => {
|
|
25
|
+
const id = service.show({ title: 'Test', duration: 0 });
|
|
26
|
+
service.dismiss(id);
|
|
27
|
+
expect(service.toasts().length).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should dismiss all toasts', () => {
|
|
31
|
+
service.show({ title: 'Test 1', duration: 0 });
|
|
32
|
+
service.show({ title: 'Test 2', duration: 0 });
|
|
33
|
+
expect(service.toasts().length).toBe(2);
|
|
34
|
+
service.dismissAll();
|
|
35
|
+
expect(service.toasts().length).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should auto-dismiss after duration', async () => {
|
|
39
|
+
service.show({ title: 'Test', duration: 50 });
|
|
40
|
+
expect(service.toasts().length).toBe(1);
|
|
41
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
42
|
+
expect(service.toasts().length).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should provide success shortcut', () => {
|
|
46
|
+
service.success('Done', 'Task completed');
|
|
47
|
+
const toast = service.toasts()[0];
|
|
48
|
+
expect(toast.variant).toBe('success');
|
|
49
|
+
expect(toast.description).toBe('Task completed');
|
|
50
|
+
service.dismiss(toast.id);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should provide error shortcut', () => {
|
|
54
|
+
service.error('Failed');
|
|
55
|
+
expect(service.toasts()[0].variant).toBe('destructive');
|
|
56
|
+
service.dismissAll();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should provide warning shortcut', () => {
|
|
60
|
+
service.warning('Caution');
|
|
61
|
+
expect(service.toasts()[0].variant).toBe('warning');
|
|
62
|
+
service.dismissAll();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should track count', () => {
|
|
66
|
+
expect(service.count()).toBe(0);
|
|
67
|
+
service.show({ title: 'A', duration: 0 });
|
|
68
|
+
service.show({ title: 'B', duration: 0 });
|
|
69
|
+
expect(service.count()).toBe(2);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Injectable, signal, computed } from '@angular/core';
|
|
2
|
+
import type { ToastConfig, ToastData } from './toast.variants';
|
|
3
|
+
|
|
4
|
+
@Injectable({ providedIn: 'root' })
|
|
5
|
+
export class SnyToastService {
|
|
6
|
+
private readonly _toasts = signal<ToastData[]>([]);
|
|
7
|
+
private readonly _timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
8
|
+
private _idCounter = 0;
|
|
9
|
+
|
|
10
|
+
readonly toasts = this._toasts.asReadonly();
|
|
11
|
+
readonly count = computed(() => this._toasts().length);
|
|
12
|
+
|
|
13
|
+
show(config: ToastConfig): string {
|
|
14
|
+
const id = `sny-toast-${++this._idCounter}`;
|
|
15
|
+
const toast: ToastData = {
|
|
16
|
+
id,
|
|
17
|
+
variant: 'default',
|
|
18
|
+
duration: 5000,
|
|
19
|
+
...config,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
this._toasts.update(toasts => [...toasts, toast]);
|
|
23
|
+
|
|
24
|
+
if (toast.duration && toast.duration > 0) {
|
|
25
|
+
const timer = setTimeout(() => this.dismiss(id), toast.duration);
|
|
26
|
+
this._timers.set(id, timer);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
dismiss(id: string): void {
|
|
33
|
+
const timer = this._timers.get(id);
|
|
34
|
+
if (timer) {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
this._timers.delete(id);
|
|
37
|
+
}
|
|
38
|
+
this._toasts.update(toasts => toasts.filter(t => t.id !== id));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
dismissAll(): void {
|
|
42
|
+
for (const timer of this._timers.values()) {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
this._timers.clear();
|
|
46
|
+
this._toasts.set([]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
success(title: string, description?: string): string {
|
|
50
|
+
return this.show({ title, description, variant: 'success' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
error(title: string, description?: string): string {
|
|
54
|
+
return this.show({ title, description, variant: 'destructive' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
warning(title: string, description?: string): string {
|
|
58
|
+
return this.show({ title, description, variant: 'warning' });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const toastVariants = cva(
|
|
4
|
+
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-sm border p-6 pr-8 shadow-lg transition-all',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
variant: {
|
|
8
|
+
default: 'bg-background border-border text-foreground',
|
|
9
|
+
destructive: 'bg-destructive border-destructive text-destructive-foreground',
|
|
10
|
+
success: 'bg-green-600 border-green-600 text-white',
|
|
11
|
+
warning: 'bg-yellow-500 border-yellow-500 text-white',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
defaultVariants: {
|
|
15
|
+
variant: 'default',
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export type ToastVariant = 'default' | 'destructive' | 'success' | 'warning';
|
|
21
|
+
export type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';
|
|
22
|
+
|
|
23
|
+
export interface ToastAction {
|
|
24
|
+
label: string;
|
|
25
|
+
onClick: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ToastConfig {
|
|
29
|
+
title: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
variant?: ToastVariant;
|
|
32
|
+
duration?: number;
|
|
33
|
+
action?: ToastAction;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ToastData extends ToastConfig {
|
|
37
|
+
id: string;
|
|
38
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyToasterComponent } from './toaster.component';
|
|
4
|
+
import { SnyToastService } from './toast.service';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyToasterComponent],
|
|
9
|
+
template: `<sny-toaster />`,
|
|
10
|
+
})
|
|
11
|
+
class TestHostComponent {}
|
|
12
|
+
|
|
13
|
+
describe('SnyToasterComponent', () => {
|
|
14
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
15
|
+
let service: SnyToastService;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
|
|
19
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
20
|
+
service = TestBed.inject(SnyToastService);
|
|
21
|
+
fixture.detectChanges();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should render region with aria-label', () => {
|
|
25
|
+
const region = fixture.nativeElement.querySelector('[role="region"]');
|
|
26
|
+
expect(region).toBeTruthy();
|
|
27
|
+
expect(region.getAttribute('aria-label')).toBe('Notifications');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should render toast items with aria-atomic="true"', () => {
|
|
31
|
+
service.show({ title: 'Test toast' });
|
|
32
|
+
fixture.detectChanges();
|
|
33
|
+
const alert = fixture.nativeElement.querySelector('[role="alert"]');
|
|
34
|
+
expect(alert).toBeTruthy();
|
|
35
|
+
expect(alert.getAttribute('aria-atomic')).toBe('true');
|
|
36
|
+
expect(alert.getAttribute('aria-live')).toBe('polite');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject, input, computed } from '@angular/core';
|
|
2
|
+
import { SnyToastService } from './toast.service';
|
|
3
|
+
import { toastVariants, type ToastPosition, type ToastVariant } from './toast.variants';
|
|
4
|
+
import { cn } from '../core/utils/cn';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
selector: 'sny-toaster',
|
|
8
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
9
|
+
template: `
|
|
10
|
+
<div [class]="containerClass()" role="region" aria-label="Notifications" tabindex="-1">
|
|
11
|
+
@for (toast of visibleToasts(); track toast.id) {
|
|
12
|
+
<div
|
|
13
|
+
[class]="toastClasses[toast.variant ?? 'default']"
|
|
14
|
+
role="alert"
|
|
15
|
+
aria-live="polite"
|
|
16
|
+
aria-atomic="true"
|
|
17
|
+
>
|
|
18
|
+
<div class="grid gap-1">
|
|
19
|
+
<div class="text-sm font-semibold">{{ toast.title }}</div>
|
|
20
|
+
@if (toast.description) {
|
|
21
|
+
<div class="text-sm opacity-90">{{ toast.description }}</div>
|
|
22
|
+
}
|
|
23
|
+
</div>
|
|
24
|
+
<div class="flex items-center gap-2">
|
|
25
|
+
@if (toast.action) {
|
|
26
|
+
<button
|
|
27
|
+
class="inline-flex h-8 shrink-0 items-center justify-center rounded-sm border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary"
|
|
28
|
+
(click)="toast.action!.onClick()"
|
|
29
|
+
>
|
|
30
|
+
{{ toast.action!.label }}
|
|
31
|
+
</button>
|
|
32
|
+
}
|
|
33
|
+
<button
|
|
34
|
+
class="absolute right-2 top-2 rounded-sm p-1 opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100 focus:opacity-100"
|
|
35
|
+
aria-label="Close"
|
|
36
|
+
(click)="dismiss(toast.id)"
|
|
37
|
+
>
|
|
38
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
}
|
|
43
|
+
</div>
|
|
44
|
+
`,
|
|
45
|
+
})
|
|
46
|
+
export class SnyToasterComponent {
|
|
47
|
+
private readonly toastService = inject(SnyToastService);
|
|
48
|
+
|
|
49
|
+
readonly position = input<ToastPosition>('bottom-right');
|
|
50
|
+
readonly maxToasts = input(5);
|
|
51
|
+
|
|
52
|
+
readonly visibleToasts = computed(() =>
|
|
53
|
+
this.toastService.toasts().slice(-this.maxToasts())
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
readonly containerClass = computed(() => {
|
|
57
|
+
const pos = this.position();
|
|
58
|
+
const base = 'fixed z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:max-w-[420px]';
|
|
59
|
+
const posMap: Record<ToastPosition, string> = {
|
|
60
|
+
'top-right': 'top-0 right-0',
|
|
61
|
+
'top-left': 'top-0 left-0',
|
|
62
|
+
'bottom-right': 'bottom-0 right-0',
|
|
63
|
+
'bottom-left': 'bottom-0 left-0',
|
|
64
|
+
'top-center': 'top-0 left-1/2 -translate-x-1/2',
|
|
65
|
+
'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2',
|
|
66
|
+
};
|
|
67
|
+
return cn(base, posMap[pos]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** Pre-computed toast classes by variant — avoids method calls in the template. */
|
|
71
|
+
readonly toastClasses: Record<ToastVariant, string> = {
|
|
72
|
+
default: cn(toastVariants({ variant: 'default' })),
|
|
73
|
+
destructive: cn(toastVariants({ variant: 'destructive' })),
|
|
74
|
+
success: cn(toastVariants({ variant: 'success' })),
|
|
75
|
+
warning: cn(toastVariants({ variant: 'warning' })),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
dismiss(id: string): void {
|
|
79
|
+
this.toastService.dismiss(id);
|
|
80
|
+
}
|
|
81
|
+
}
|