@sonny-ui/core 0.1.0-alpha.1 → 0.1.0-alpha.11
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 +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 +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,19 @@
|
|
|
1
|
+
import { Directive, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import { linkVariants, type LinkVariant } from './link.variants';
|
|
4
|
+
|
|
5
|
+
@Directive({
|
|
6
|
+
selector: 'a[snyLink]',
|
|
7
|
+
standalone: true,
|
|
8
|
+
host: {
|
|
9
|
+
'[class]': 'computedClass()',
|
|
10
|
+
},
|
|
11
|
+
})
|
|
12
|
+
export class SnyLinkDirective {
|
|
13
|
+
readonly variant = input<LinkVariant>('default');
|
|
14
|
+
readonly class = input<string>('');
|
|
15
|
+
|
|
16
|
+
protected readonly computedClass = computed(() =>
|
|
17
|
+
cn(linkVariants({ variant: this.variant() }), this.class())
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const linkVariants = cva(
|
|
4
|
+
'inline-flex items-center gap-1 underline-offset-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
variant: {
|
|
8
|
+
default: 'text-foreground underline hover:text-foreground/80',
|
|
9
|
+
primary: 'text-primary underline hover:text-primary/80',
|
|
10
|
+
secondary: 'text-muted-foreground underline hover:text-foreground',
|
|
11
|
+
hover: 'text-foreground no-underline hover:underline',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
defaultVariants: {
|
|
15
|
+
variant: 'default',
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export type LinkVariant = 'default' | 'primary' | 'secondary' | 'hover';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyListDirective, SnyListItemDirective, SnyListItemContentDirective, type ListVariant } from './list.directives';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [SnyListDirective, SnyListItemDirective, SnyListItemContentDirective],
|
|
8
|
+
template: `
|
|
9
|
+
<div snyList [variant]="variant()">
|
|
10
|
+
<div snyListItem [active]="active()" [disabled]="disabled()">
|
|
11
|
+
<div snyListItemContent>Item 1</div>
|
|
12
|
+
</div>
|
|
13
|
+
<div snyListItem>
|
|
14
|
+
<div snyListItemContent>Item 2</div>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
`,
|
|
18
|
+
})
|
|
19
|
+
class TestHostComponent {
|
|
20
|
+
variant = signal<ListVariant>('default');
|
|
21
|
+
active = signal(false);
|
|
22
|
+
disabled = signal(false);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('SnyListDirective', () => {
|
|
26
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
|
|
30
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
31
|
+
fixture.detectChanges();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should render with list role', () => {
|
|
35
|
+
const list = fixture.nativeElement.querySelector('[snyList]');
|
|
36
|
+
expect(list.getAttribute('role')).toBe('list');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should render items with listitem role', () => {
|
|
40
|
+
const items = fixture.nativeElement.querySelectorAll('[snyListItem]');
|
|
41
|
+
expect(items.length).toBe(2);
|
|
42
|
+
expect(items[0].getAttribute('role')).toBe('listitem');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should apply bordered variant', () => {
|
|
46
|
+
fixture.componentInstance.variant.set('bordered');
|
|
47
|
+
fixture.detectChanges();
|
|
48
|
+
const list = fixture.nativeElement.querySelector('[snyList]');
|
|
49
|
+
expect(list.className).toContain('border');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should apply active state', () => {
|
|
53
|
+
fixture.componentInstance.active.set(true);
|
|
54
|
+
fixture.detectChanges();
|
|
55
|
+
const item = fixture.nativeElement.querySelector('[snyListItem]');
|
|
56
|
+
expect(item.className).toContain('bg-accent');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should set aria-disabled', () => {
|
|
60
|
+
fixture.componentInstance.disabled.set(true);
|
|
61
|
+
fixture.detectChanges();
|
|
62
|
+
const item = fixture.nativeElement.querySelector('[snyListItem]');
|
|
63
|
+
expect(item.getAttribute('aria-disabled')).toBe('true');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Directive, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
|
|
4
|
+
export type ListVariant = 'default' | 'bordered' | 'hover';
|
|
5
|
+
|
|
6
|
+
@Directive({
|
|
7
|
+
selector: '[snyList]',
|
|
8
|
+
standalone: true,
|
|
9
|
+
host: {
|
|
10
|
+
'role': 'list',
|
|
11
|
+
'[class]': 'computedClass()',
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
export class SnyListDirective {
|
|
15
|
+
readonly variant = input<ListVariant>('default');
|
|
16
|
+
readonly class = input<string>('');
|
|
17
|
+
|
|
18
|
+
protected readonly computedClass = computed(() => {
|
|
19
|
+
const v = this.variant();
|
|
20
|
+
const variantClass =
|
|
21
|
+
v === 'bordered' ? 'divide-y divide-border border rounded-md' :
|
|
22
|
+
v === 'hover' ? '[&>[snyListItem]]:hover:bg-accent' :
|
|
23
|
+
'';
|
|
24
|
+
return cn('flex flex-col', variantClass, this.class());
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Directive({
|
|
29
|
+
selector: '[snyListItem]',
|
|
30
|
+
standalone: true,
|
|
31
|
+
host: {
|
|
32
|
+
'role': 'listitem',
|
|
33
|
+
'[class]': 'computedClass()',
|
|
34
|
+
'[attr.aria-disabled]': 'disabled() || null',
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
export class SnyListItemDirective {
|
|
38
|
+
readonly active = input(false);
|
|
39
|
+
readonly disabled = input(false);
|
|
40
|
+
readonly class = input<string>('');
|
|
41
|
+
|
|
42
|
+
protected readonly computedClass = computed(() =>
|
|
43
|
+
cn(
|
|
44
|
+
'flex items-center gap-3 px-3 py-2 transition-colors',
|
|
45
|
+
this.active() && 'bg-accent text-accent-foreground',
|
|
46
|
+
this.disabled() && 'opacity-50 pointer-events-none',
|
|
47
|
+
this.class()
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Directive({
|
|
53
|
+
selector: '[snyListItemIcon]',
|
|
54
|
+
standalone: true,
|
|
55
|
+
host: { '[class]': 'computedClass()' },
|
|
56
|
+
})
|
|
57
|
+
export class SnyListItemIconDirective {
|
|
58
|
+
readonly class = input<string>('');
|
|
59
|
+
protected readonly computedClass = computed(() =>
|
|
60
|
+
cn('flex-shrink-0 text-muted-foreground', this.class())
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Directive({
|
|
65
|
+
selector: '[snyListItemContent]',
|
|
66
|
+
standalone: true,
|
|
67
|
+
host: { '[class]': 'computedClass()' },
|
|
68
|
+
})
|
|
69
|
+
export class SnyListItemContentDirective {
|
|
70
|
+
readonly class = input<string>('');
|
|
71
|
+
protected readonly computedClass = computed(() =>
|
|
72
|
+
cn('flex-1 min-w-0', this.class())
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Directive({
|
|
77
|
+
selector: '[snyListItemAction]',
|
|
78
|
+
standalone: true,
|
|
79
|
+
host: { '[class]': 'computedClass()' },
|
|
80
|
+
})
|
|
81
|
+
export class SnyListItemActionDirective {
|
|
82
|
+
readonly class = input<string>('');
|
|
83
|
+
protected readonly computedClass = computed(() =>
|
|
84
|
+
cn('flex-shrink-0', this.class())
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyLoaderComponent } from './loader.component';
|
|
4
|
+
import type { LoaderVariant, LoaderSize } from './loader.variants';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyLoaderComponent],
|
|
9
|
+
template: `<sny-loader [variant]="variant()" [size]="size()" />`,
|
|
10
|
+
})
|
|
11
|
+
class TestHostComponent {
|
|
12
|
+
variant = signal<LoaderVariant>('spinner');
|
|
13
|
+
size = signal<LoaderSize>('md');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('SnyLoaderComponent', () => {
|
|
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('sny-loader');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should have status role', () => {
|
|
31
|
+
expect(el.getAttribute('role')).toBe('status');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should render spinner by default', () => {
|
|
35
|
+
expect(el.querySelector('svg')).toBeTruthy();
|
|
36
|
+
expect(el.className).toContain('h-6');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should render dots variant', () => {
|
|
40
|
+
fixture.componentInstance.variant.set('dots');
|
|
41
|
+
fixture.detectChanges();
|
|
42
|
+
const dots = el.querySelectorAll('.rounded-full');
|
|
43
|
+
expect(dots.length).toBe(3);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should render bars variant', () => {
|
|
47
|
+
fixture.componentInstance.variant.set('bars');
|
|
48
|
+
fixture.detectChanges();
|
|
49
|
+
const bars = el.querySelectorAll('.rounded-sm');
|
|
50
|
+
expect(bars.length).toBe(4);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should apply lg size', () => {
|
|
54
|
+
fixture.componentInstance.size.set('lg');
|
|
55
|
+
fixture.detectChanges();
|
|
56
|
+
expect(el.className).toContain('h-8');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import { loaderVariants, type LoaderSize, type LoaderVariant } from './loader.variants';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'sny-loader',
|
|
7
|
+
standalone: true,
|
|
8
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
9
|
+
host: {
|
|
10
|
+
'[class]': 'computedClass()',
|
|
11
|
+
role: 'status',
|
|
12
|
+
'[attr.aria-label]': '"Loading"',
|
|
13
|
+
},
|
|
14
|
+
template: `
|
|
15
|
+
@switch (variant()) {
|
|
16
|
+
@case ('spinner') {
|
|
17
|
+
<svg class="animate-spin h-full w-full" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
18
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
19
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
20
|
+
</svg>
|
|
21
|
+
}
|
|
22
|
+
@case ('dots') {
|
|
23
|
+
<span class="flex items-center gap-1">
|
|
24
|
+
<span class="h-1.5 w-1.5 rounded-full bg-current animate-bounce [animation-delay:-0.3s]"></span>
|
|
25
|
+
<span class="h-1.5 w-1.5 rounded-full bg-current animate-bounce [animation-delay:-0.15s]"></span>
|
|
26
|
+
<span class="h-1.5 w-1.5 rounded-full bg-current animate-bounce"></span>
|
|
27
|
+
</span>
|
|
28
|
+
}
|
|
29
|
+
@case ('bars') {
|
|
30
|
+
<span class="flex items-end gap-0.5 h-full">
|
|
31
|
+
<span class="w-1 bg-current animate-pulse rounded-sm [animation-delay:-0.3s]" style="height:60%"></span>
|
|
32
|
+
<span class="w-1 bg-current animate-pulse rounded-sm [animation-delay:-0.15s]" style="height:100%"></span>
|
|
33
|
+
<span class="w-1 bg-current animate-pulse rounded-sm" style="height:40%"></span>
|
|
34
|
+
<span class="w-1 bg-current animate-pulse rounded-sm [animation-delay:-0.2s]" style="height:80%"></span>
|
|
35
|
+
</span>
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
`,
|
|
39
|
+
})
|
|
40
|
+
export class SnyLoaderComponent {
|
|
41
|
+
readonly variant = input<LoaderVariant>('spinner');
|
|
42
|
+
readonly size = input<LoaderSize>('md');
|
|
43
|
+
readonly class = input<string>('');
|
|
44
|
+
|
|
45
|
+
protected readonly computedClass = computed(() =>
|
|
46
|
+
cn(loaderVariants({ size: this.size() }), this.class())
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const loaderVariants = cva(
|
|
4
|
+
'inline-flex items-center justify-center text-current',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: 'h-4 w-4',
|
|
9
|
+
md: 'h-6 w-6',
|
|
10
|
+
lg: 'h-8 w-8',
|
|
11
|
+
xl: 'h-12 w-12',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
defaultVariants: {
|
|
15
|
+
size: 'md',
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export type LoaderSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
21
|
+
export type LoaderVariant = 'spinner' | 'dots' | 'bars';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Observable } from 'rxjs';
|
|
2
|
+
|
|
3
|
+
/** Structural interface for the subset of CDK DialogRef we consume. */
|
|
4
|
+
interface CdkDialogRefLike<R> {
|
|
5
|
+
close(result?: R): void;
|
|
6
|
+
readonly closed: Observable<R | undefined>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class SnyDialogRef<R = unknown> {
|
|
10
|
+
constructor(private readonly cdkRef: CdkDialogRefLike<R>) {}
|
|
11
|
+
|
|
12
|
+
close(result?: R): void {
|
|
13
|
+
this.cdkRef.close(result);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get closed(): Observable<R | undefined> {
|
|
17
|
+
return this.cdkRef.closed;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Directive, computed, input, inject } from '@angular/core';
|
|
2
|
+
import { DialogRef } from '@angular/cdk/dialog';
|
|
3
|
+
import { cn } from '../core/utils/cn';
|
|
4
|
+
|
|
5
|
+
@Directive({
|
|
6
|
+
selector: '[snyDialogHeader]',
|
|
7
|
+
standalone: true,
|
|
8
|
+
host: { '[class]': 'computedClass()' },
|
|
9
|
+
})
|
|
10
|
+
export class SnyDialogHeaderDirective {
|
|
11
|
+
readonly class = input<string>('');
|
|
12
|
+
protected readonly computedClass = computed(() =>
|
|
13
|
+
cn('flex flex-col space-y-1.5 text-center sm:text-left', this.class())
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Directive({
|
|
18
|
+
selector: '[snyDialogTitle]',
|
|
19
|
+
standalone: true,
|
|
20
|
+
host: { '[class]': 'computedClass()' },
|
|
21
|
+
})
|
|
22
|
+
export class SnyDialogTitleDirective {
|
|
23
|
+
readonly class = input<string>('');
|
|
24
|
+
protected readonly computedClass = computed(() =>
|
|
25
|
+
cn('text-lg font-semibold leading-none tracking-tight', this.class())
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Directive({
|
|
30
|
+
selector: '[snyDialogDescription]',
|
|
31
|
+
standalone: true,
|
|
32
|
+
host: { '[class]': 'computedClass()' },
|
|
33
|
+
})
|
|
34
|
+
export class SnyDialogDescriptionDirective {
|
|
35
|
+
readonly class = input<string>('');
|
|
36
|
+
protected readonly computedClass = computed(() =>
|
|
37
|
+
cn('text-sm text-muted-foreground', this.class())
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Directive({
|
|
42
|
+
selector: '[snyDialogContent]',
|
|
43
|
+
standalone: true,
|
|
44
|
+
host: { '[class]': 'computedClass()' },
|
|
45
|
+
})
|
|
46
|
+
export class SnyDialogContentDirective {
|
|
47
|
+
readonly class = input<string>('');
|
|
48
|
+
protected readonly computedClass = computed(() =>
|
|
49
|
+
cn(
|
|
50
|
+
'relative bg-background rounded-sm border border-border shadow-lg p-6 w-full max-w-lg mx-auto',
|
|
51
|
+
this.class()
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Directive({
|
|
57
|
+
selector: '[snyDialogFooter]',
|
|
58
|
+
standalone: true,
|
|
59
|
+
host: { '[class]': 'computedClass()' },
|
|
60
|
+
})
|
|
61
|
+
export class SnyDialogFooterDirective {
|
|
62
|
+
readonly class = input<string>('');
|
|
63
|
+
protected readonly computedClass = computed(() =>
|
|
64
|
+
cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', this.class())
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Directive({
|
|
69
|
+
selector: '[snyDialogClose]',
|
|
70
|
+
standalone: true,
|
|
71
|
+
host: {
|
|
72
|
+
'[class]': 'computedClass()',
|
|
73
|
+
'(click)': 'onClick()',
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
export class SnyDialogCloseDirective {
|
|
77
|
+
readonly class = input<string>('');
|
|
78
|
+
protected readonly computedClass = computed(() =>
|
|
79
|
+
cn(
|
|
80
|
+
'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none',
|
|
81
|
+
this.class()
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
private readonly dialogRef = inject(DialogRef, { optional: true });
|
|
86
|
+
|
|
87
|
+
onClick(): void {
|
|
88
|
+
this.dialogRef?.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { TestBed } from '@angular/core/testing';
|
|
3
|
+
import { DialogModule } from '@angular/cdk/dialog';
|
|
4
|
+
import { SnyDialogService } from './dialog.service';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
template: `<div>Dialog Content</div>`,
|
|
9
|
+
})
|
|
10
|
+
class TestDialogComponent {}
|
|
11
|
+
|
|
12
|
+
describe('SnyDialogService', () => {
|
|
13
|
+
let service: SnyDialogService;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
TestBed.configureTestingModule({
|
|
17
|
+
imports: [DialogModule],
|
|
18
|
+
});
|
|
19
|
+
service = TestBed.inject(SnyDialogService);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
service.closeAll();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should open a dialog', () => {
|
|
27
|
+
const ref = service.open(TestDialogComponent);
|
|
28
|
+
expect(ref).toBeTruthy();
|
|
29
|
+
ref.close();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should close a dialog', async () => {
|
|
33
|
+
const ref = service.open(TestDialogComponent);
|
|
34
|
+
let closed = false;
|
|
35
|
+
ref.closed.subscribe(() => (closed = true));
|
|
36
|
+
ref.close();
|
|
37
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
38
|
+
expect(closed).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should close all dialogs', () => {
|
|
42
|
+
service.open(TestDialogComponent);
|
|
43
|
+
service.open(TestDialogComponent);
|
|
44
|
+
service.closeAll();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should accept width config', () => {
|
|
48
|
+
const ref = service.open(TestDialogComponent, { width: '600px' });
|
|
49
|
+
expect(ref).toBeTruthy();
|
|
50
|
+
ref.close();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Injectable, inject, InjectionToken } from '@angular/core';
|
|
2
|
+
import { Dialog, DialogRef as CdkDialogRef } from '@angular/cdk/dialog';
|
|
3
|
+
import type { ComponentType } from '@angular/cdk/overlay';
|
|
4
|
+
import { SnyDialogRef } from './dialog-ref';
|
|
5
|
+
import { DEFAULT_DIALOG_CONFIG, type SnyDialogConfig } from './dialog.types';
|
|
6
|
+
|
|
7
|
+
export const SNY_DIALOG_DATA = new InjectionToken<unknown>('SNY_DIALOG_DATA');
|
|
8
|
+
|
|
9
|
+
@Injectable({ providedIn: 'root' })
|
|
10
|
+
export class SnyDialogService {
|
|
11
|
+
private readonly cdkDialog = inject(Dialog);
|
|
12
|
+
|
|
13
|
+
open<T, R = unknown>(
|
|
14
|
+
component: ComponentType<T>,
|
|
15
|
+
config: SnyDialogConfig = {}
|
|
16
|
+
): SnyDialogRef<R> {
|
|
17
|
+
const merged = { ...DEFAULT_DIALOG_CONFIG, ...config };
|
|
18
|
+
|
|
19
|
+
// CDK's disableClose controls both backdrop and ESC together.
|
|
20
|
+
// To support independent closeOnBackdrop / closeOnEsc, we disable both
|
|
21
|
+
// at the CDK level and handle them manually.
|
|
22
|
+
const disableClose = !merged.closeOnBackdrop || !merged.closeOnEsc;
|
|
23
|
+
|
|
24
|
+
const cdkRef: CdkDialogRef<R, T> = this.cdkDialog.open(component, {
|
|
25
|
+
width: merged.width,
|
|
26
|
+
maxWidth: merged.maxWidth,
|
|
27
|
+
disableClose,
|
|
28
|
+
hasBackdrop: true,
|
|
29
|
+
backdropClass: 'sny-dialog-backdrop',
|
|
30
|
+
panelClass: 'sny-dialog-panel',
|
|
31
|
+
ariaLabelledBy: merged.ariaLabelledBy,
|
|
32
|
+
ariaDescribedBy: merged.ariaDescribedBy,
|
|
33
|
+
data: merged.data,
|
|
34
|
+
providers: merged.data != null
|
|
35
|
+
? [{ provide: SNY_DIALOG_DATA, useValue: merged.data }]
|
|
36
|
+
: [],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// When CDK disableClose is true, manually handle backdrop/ESC based on config
|
|
40
|
+
if (disableClose) {
|
|
41
|
+
if (merged.closeOnBackdrop) {
|
|
42
|
+
const sub = cdkRef.backdropClick.subscribe(() => cdkRef.close());
|
|
43
|
+
cdkRef.closed.subscribe(() => sub.unsubscribe());
|
|
44
|
+
}
|
|
45
|
+
if (merged.closeOnEsc) {
|
|
46
|
+
const sub = cdkRef.keydownEvents.subscribe(event => {
|
|
47
|
+
if (event.key === 'Escape') {
|
|
48
|
+
cdkRef.close();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
cdkRef.closed.subscribe(() => sub.unsubscribe());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return new SnyDialogRef<R>(cdkRef);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
closeAll(): void {
|
|
59
|
+
this.cdkDialog.closeAll();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface SnyDialogConfig {
|
|
2
|
+
width?: string;
|
|
3
|
+
maxWidth?: string;
|
|
4
|
+
closeOnBackdrop?: boolean;
|
|
5
|
+
closeOnEsc?: boolean;
|
|
6
|
+
data?: unknown;
|
|
7
|
+
ariaLabelledBy?: string;
|
|
8
|
+
ariaDescribedBy?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_DIALOG_CONFIG: SnyDialogConfig = {
|
|
12
|
+
width: '28rem',
|
|
13
|
+
maxWidth: '90vw',
|
|
14
|
+
closeOnBackdrop: true,
|
|
15
|
+
closeOnEsc: true,
|
|
16
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { SnyDialogService, SNY_DIALOG_DATA } from './dialog.service';
|
|
2
|
+
export { SnyDialogRef } from './dialog-ref';
|
|
3
|
+
export {
|
|
4
|
+
SnyDialogHeaderDirective,
|
|
5
|
+
SnyDialogTitleDirective,
|
|
6
|
+
SnyDialogDescriptionDirective,
|
|
7
|
+
SnyDialogContentDirective,
|
|
8
|
+
SnyDialogFooterDirective,
|
|
9
|
+
SnyDialogCloseDirective,
|
|
10
|
+
} from './dialog.directives';
|
|
11
|
+
export { type SnyDialogConfig } from './dialog.types';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyNavbarDirective, SnyNavbarBrandDirective, SnyNavbarContentDirective, SnyNavbarEndDirective, type NavbarVariant } from './navbar.directives';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [SnyNavbarDirective, SnyNavbarBrandDirective, SnyNavbarContentDirective, SnyNavbarEndDirective],
|
|
8
|
+
template: `
|
|
9
|
+
<nav snyNavbar [variant]="variant()" [sticky]="sticky()">
|
|
10
|
+
<div snyNavbarBrand>Logo</div>
|
|
11
|
+
<div snyNavbarContent>Links</div>
|
|
12
|
+
<div snyNavbarEnd>Actions</div>
|
|
13
|
+
</nav>
|
|
14
|
+
`,
|
|
15
|
+
})
|
|
16
|
+
class TestHostComponent {
|
|
17
|
+
variant = signal<NavbarVariant>('default');
|
|
18
|
+
sticky = signal(false);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('SnyNavbarDirective', () => {
|
|
22
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
23
|
+
let nav: HTMLElement;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
|
|
27
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
28
|
+
fixture.detectChanges();
|
|
29
|
+
nav = fixture.nativeElement.querySelector('[snyNavbar]');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should apply default classes', () => {
|
|
33
|
+
expect(nav.className).toContain('bg-background');
|
|
34
|
+
expect(nav.className).toContain('flex');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should apply bordered variant', () => {
|
|
38
|
+
fixture.componentInstance.variant.set('bordered');
|
|
39
|
+
fixture.detectChanges();
|
|
40
|
+
expect(nav.className).toContain('border-b');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should apply sticky class', () => {
|
|
44
|
+
fixture.componentInstance.sticky.set(true);
|
|
45
|
+
fixture.detectChanges();
|
|
46
|
+
expect(nav.className).toContain('sticky');
|
|
47
|
+
expect(nav.className).toContain('top-0');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should render brand, content, end sections', () => {
|
|
51
|
+
expect(fixture.nativeElement.querySelector('[snyNavbarBrand]')).toBeTruthy();
|
|
52
|
+
expect(fixture.nativeElement.querySelector('[snyNavbarContent]')).toBeTruthy();
|
|
53
|
+
expect(fixture.nativeElement.querySelector('[snyNavbarEnd]')).toBeTruthy();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should have default aria-label "Main navigation"', () => {
|
|
57
|
+
expect(nav.getAttribute('aria-label')).toBe('Main navigation');
|
|
58
|
+
});
|
|
59
|
+
});
|