@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,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
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Directive, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
|
|
4
|
+
export type NavbarVariant = 'default' | 'bordered' | 'floating';
|
|
5
|
+
|
|
6
|
+
@Directive({
|
|
7
|
+
selector: '[snyNavbar]',
|
|
8
|
+
standalone: true,
|
|
9
|
+
host: {
|
|
10
|
+
'role': 'navigation',
|
|
11
|
+
'[attr.aria-label]': 'ariaLabel()',
|
|
12
|
+
'[class]': 'computedClass()',
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
export class SnyNavbarDirective {
|
|
16
|
+
readonly variant = input<NavbarVariant>('default');
|
|
17
|
+
readonly sticky = input(false);
|
|
18
|
+
readonly ariaLabel = input<string>('Main navigation');
|
|
19
|
+
readonly class = input<string>('');
|
|
20
|
+
|
|
21
|
+
protected readonly computedClass = computed(() => {
|
|
22
|
+
const v = this.variant();
|
|
23
|
+
const base = 'flex items-center justify-between px-4 py-2 w-full bg-background';
|
|
24
|
+
const variantClass =
|
|
25
|
+
v === 'bordered' ? 'border-b border-border' :
|
|
26
|
+
v === 'floating' ? 'mx-4 mt-2 rounded-lg border border-border shadow-sm' :
|
|
27
|
+
'';
|
|
28
|
+
const stickyClass = this.sticky() ? 'sticky top-0 z-50' : '';
|
|
29
|
+
return cn(base, variantClass, stickyClass, this.class());
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Directive({
|
|
34
|
+
selector: '[snyNavbarBrand]',
|
|
35
|
+
standalone: true,
|
|
36
|
+
host: { '[class]': 'computedClass()' },
|
|
37
|
+
})
|
|
38
|
+
export class SnyNavbarBrandDirective {
|
|
39
|
+
readonly class = input<string>('');
|
|
40
|
+
protected readonly computedClass = computed(() => cn('flex items-center gap-2 font-bold text-lg', this.class()));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Directive({
|
|
44
|
+
selector: '[snyNavbarContent]',
|
|
45
|
+
standalone: true,
|
|
46
|
+
host: { '[class]': 'computedClass()' },
|
|
47
|
+
})
|
|
48
|
+
export class SnyNavbarContentDirective {
|
|
49
|
+
readonly class = input<string>('');
|
|
50
|
+
protected readonly computedClass = computed(() => cn('flex items-center gap-1', this.class()));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Directive({
|
|
54
|
+
selector: '[snyNavbarEnd]',
|
|
55
|
+
standalone: true,
|
|
56
|
+
host: { '[class]': 'computedClass()' },
|
|
57
|
+
})
|
|
58
|
+
export class SnyNavbarEndDirective {
|
|
59
|
+
readonly class = input<string>('');
|
|
60
|
+
protected readonly computedClass = computed(() => cn('flex items-center gap-2 ml-auto', this.class()));
|
|
61
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyPaginationComponent } from './pagination.component';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [SnyPaginationComponent],
|
|
8
|
+
template: `<sny-pagination [(currentPage)]="currentPage" [totalPages]="totalPages()" />`,
|
|
9
|
+
})
|
|
10
|
+
class TestHostComponent {
|
|
11
|
+
currentPage = signal(1);
|
|
12
|
+
totalPages = signal(10);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('SnyPaginationComponent', () => {
|
|
16
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
17
|
+
let host: HTMLElement;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
|
|
21
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
22
|
+
fixture.detectChanges();
|
|
23
|
+
host = fixture.nativeElement.querySelector('sny-pagination');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should render with navigation role', () => {
|
|
27
|
+
expect(host.getAttribute('role')).toBe('navigation');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should render page buttons', () => {
|
|
31
|
+
const buttons = host.querySelectorAll('button');
|
|
32
|
+
expect(buttons.length).toBeGreaterThan(2); // at least prev + page + next
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should mark current page with aria-current', () => {
|
|
36
|
+
const currentBtn = host.querySelector('[aria-current="page"]');
|
|
37
|
+
expect(currentBtn).toBeTruthy();
|
|
38
|
+
expect(currentBtn!.textContent?.trim()).toBe('1');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should navigate to next page', () => {
|
|
42
|
+
const nextBtn = host.querySelector('[aria-label="Go to next page"]') as HTMLButtonElement;
|
|
43
|
+
nextBtn.click();
|
|
44
|
+
fixture.detectChanges();
|
|
45
|
+
expect(fixture.componentInstance.currentPage()).toBe(2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should disable prev on first page', () => {
|
|
49
|
+
const prevBtn = host.querySelector('[aria-label="Go to previous page"]') as HTMLButtonElement;
|
|
50
|
+
expect(prevBtn.disabled).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should disable next on last page', () => {
|
|
54
|
+
fixture.componentInstance.currentPage.set(10);
|
|
55
|
+
fixture.detectChanges();
|
|
56
|
+
const nextBtn = host.querySelector('[aria-label="Go to next page"]') as HTMLButtonElement;
|
|
57
|
+
expect(nextBtn.disabled).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, input, model } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import {
|
|
4
|
+
paginationItemVariants,
|
|
5
|
+
type PaginationVariant,
|
|
6
|
+
type PaginationSize,
|
|
7
|
+
} from './pagination.variants';
|
|
8
|
+
|
|
9
|
+
function computePageRange(
|
|
10
|
+
totalPages: number,
|
|
11
|
+
currentPage: number,
|
|
12
|
+
siblingCount: number,
|
|
13
|
+
boundaryCount: number
|
|
14
|
+
): (number | 'ellipsis')[] {
|
|
15
|
+
const range = (start: number, end: number) =>
|
|
16
|
+
Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
|
17
|
+
|
|
18
|
+
const startPages = range(1, Math.min(boundaryCount, totalPages));
|
|
19
|
+
const endPages = range(Math.max(totalPages - boundaryCount + 1, boundaryCount + 1), totalPages);
|
|
20
|
+
|
|
21
|
+
const siblingsStart = Math.max(
|
|
22
|
+
Math.min(currentPage - siblingCount, totalPages - boundaryCount - siblingCount * 2 - 1),
|
|
23
|
+
boundaryCount + 2
|
|
24
|
+
);
|
|
25
|
+
const siblingsEnd = Math.min(
|
|
26
|
+
Math.max(currentPage + siblingCount, boundaryCount + siblingCount * 2 + 2),
|
|
27
|
+
endPages.length > 0 ? endPages[0] - 2 : totalPages - 1
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const result: (number | 'ellipsis')[] = [...startPages];
|
|
31
|
+
|
|
32
|
+
if (siblingsStart > boundaryCount + 2) {
|
|
33
|
+
result.push('ellipsis');
|
|
34
|
+
} else if (boundaryCount + 1 < totalPages - boundaryCount) {
|
|
35
|
+
result.push(boundaryCount + 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
result.push(...range(siblingsStart, siblingsEnd));
|
|
39
|
+
|
|
40
|
+
if (siblingsEnd < totalPages - boundaryCount - 1) {
|
|
41
|
+
result.push('ellipsis');
|
|
42
|
+
} else if (totalPages - boundaryCount > boundaryCount) {
|
|
43
|
+
result.push(totalPages - boundaryCount);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
result.push(...endPages);
|
|
47
|
+
|
|
48
|
+
return [...new Set(result)].sort((a, b) => {
|
|
49
|
+
if (a === 'ellipsis') return 0;
|
|
50
|
+
if (b === 'ellipsis') return 0;
|
|
51
|
+
return a - b;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Component({
|
|
56
|
+
selector: 'sny-pagination',
|
|
57
|
+
standalone: true,
|
|
58
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
59
|
+
host: {
|
|
60
|
+
'role': 'navigation',
|
|
61
|
+
'aria-label': 'Pagination',
|
|
62
|
+
},
|
|
63
|
+
template: `
|
|
64
|
+
<div class="flex items-center gap-1">
|
|
65
|
+
<button
|
|
66
|
+
[class]="navBtnClass()"
|
|
67
|
+
[disabled]="!hasPrev()"
|
|
68
|
+
[attr.aria-label]="'Go to previous page'"
|
|
69
|
+
(click)="prev()"
|
|
70
|
+
>
|
|
71
|
+
<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="m15 18-6-6 6-6"/></svg>
|
|
72
|
+
</button>
|
|
73
|
+
|
|
74
|
+
@for (page of pages(); track $index) {
|
|
75
|
+
@if (page === 'ellipsis') {
|
|
76
|
+
<span class="flex h-9 w-9 items-center justify-center" aria-hidden="true">...</span>
|
|
77
|
+
} @else {
|
|
78
|
+
<button
|
|
79
|
+
[class]="pageClass(page)"
|
|
80
|
+
[attr.aria-label]="'Page ' + page"
|
|
81
|
+
[attr.aria-current]="page === currentPage() ? 'page' : null"
|
|
82
|
+
(click)="goToPage(page)"
|
|
83
|
+
>
|
|
84
|
+
{{ page }}
|
|
85
|
+
</button>
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
<button
|
|
90
|
+
[class]="navBtnClass()"
|
|
91
|
+
[disabled]="!hasNext()"
|
|
92
|
+
[attr.aria-label]="'Go to next page'"
|
|
93
|
+
(click)="next()"
|
|
94
|
+
>
|
|
95
|
+
<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="m9 18 6-6-6-6"/></svg>
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
`,
|
|
99
|
+
})
|
|
100
|
+
export class SnyPaginationComponent {
|
|
101
|
+
readonly currentPage = model(1);
|
|
102
|
+
readonly totalPages = input.required<number>();
|
|
103
|
+
readonly siblingCount = input(1);
|
|
104
|
+
readonly boundaryCount = input(1);
|
|
105
|
+
readonly size = input<PaginationSize>('md');
|
|
106
|
+
readonly variant = input<PaginationVariant>('default');
|
|
107
|
+
readonly class = input<string>('');
|
|
108
|
+
|
|
109
|
+
readonly pages = computed(() =>
|
|
110
|
+
computePageRange(this.totalPages(), this.currentPage(), this.siblingCount(), this.boundaryCount())
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
readonly hasPrev = computed(() => this.currentPage() > 1);
|
|
114
|
+
readonly hasNext = computed(() => this.currentPage() < this.totalPages());
|
|
115
|
+
|
|
116
|
+
goToPage(page: number | 'ellipsis'): void {
|
|
117
|
+
if (page === 'ellipsis') return;
|
|
118
|
+
this.currentPage.set(page);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
prev(): void {
|
|
122
|
+
if (this.hasPrev()) this.currentPage.update((p) => p - 1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
next(): void {
|
|
126
|
+
if (this.hasNext()) this.currentPage.update((p) => p + 1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pageClass(page: number): string {
|
|
130
|
+
return cn(
|
|
131
|
+
paginationItemVariants({
|
|
132
|
+
variant: this.variant(),
|
|
133
|
+
size: this.size(),
|
|
134
|
+
active: page === this.currentPage(),
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
navBtnClass(): string {
|
|
140
|
+
return cn(
|
|
141
|
+
paginationItemVariants({ variant: this.variant(), size: this.size(), active: false })
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const paginationItemVariants = cva(
|
|
4
|
+
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
variant: {
|
|
8
|
+
default: 'bg-background hover:bg-accent hover:text-accent-foreground',
|
|
9
|
+
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
|
10
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
11
|
+
},
|
|
12
|
+
size: {
|
|
13
|
+
sm: 'h-8 w-8 text-xs',
|
|
14
|
+
md: 'h-9 w-9',
|
|
15
|
+
lg: 'h-10 w-10',
|
|
16
|
+
},
|
|
17
|
+
active: {
|
|
18
|
+
true: 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground',
|
|
19
|
+
false: '',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: {
|
|
23
|
+
variant: 'default',
|
|
24
|
+
size: 'md',
|
|
25
|
+
active: false,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export type PaginationVariant = 'default' | 'outline' | 'ghost';
|
|
31
|
+
export type PaginationSize = 'sm' | 'md' | 'lg';
|