@sonny-ui/core 0.1.0-alpha.7 → 0.1.0-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lib/accordion/accordion.directives.spec.ts +95 -0
- package/src/lib/accordion/accordion.directives.ts +104 -0
- package/src/lib/accordion/index.ts +8 -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/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/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/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 +93 -0
- package/src/lib/combobox/combobox.component.ts +236 -0
- package/src/lib/combobox/combobox.variants.ts +19 -0
- package/src/lib/combobox/index.ts +2 -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/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 +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/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/select/index.ts +2 -0
- package/src/lib/select/select.component.spec.ts +56 -0
- package/src/lib/select/select.component.ts +206 -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 +55 -0
- package/src/lib/skeleton/skeleton.directive.ts +18 -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 +55 -0
- package/src/lib/slider/slider.component.ts +141 -0
- package/src/lib/slider/slider.variants.ts +25 -0
- package/src/lib/switch/index.ts +2 -0
- package/src/lib/switch/switch.component.spec.ts +50 -0
- package/src/lib/switch/switch.component.ts +43 -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 +66 -0
- package/src/lib/tabs/tabs.directives.ts +91 -0
- package/src/lib/tabs/tabs.variants.ts +17 -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.ts +80 -0
- package/src/lib/toggle/index.ts +2 -0
- package/src/lib/toggle/toggle.directive.spec.ts +52 -0
- package/src/lib/toggle/toggle.directive.ts +27 -0
- package/src/lib/toggle/toggle.variants.ts +25 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Component, inject } from '@angular/core';
|
|
2
|
+
import { TestBed } from '@angular/core/testing';
|
|
3
|
+
import { DialogModule, DialogRef } from '@angular/cdk/dialog';
|
|
4
|
+
import { SnySheetService } from './sheet.service';
|
|
5
|
+
import {
|
|
6
|
+
SnySheetHeaderDirective,
|
|
7
|
+
SnySheetTitleDirective,
|
|
8
|
+
SnySheetDescriptionDirective,
|
|
9
|
+
SnySheetCloseDirective,
|
|
10
|
+
} from './sheet.directives';
|
|
11
|
+
import { SnyButtonDirective } from '../button/button.directive';
|
|
12
|
+
|
|
13
|
+
@Component({
|
|
14
|
+
standalone: true,
|
|
15
|
+
imports: [
|
|
16
|
+
SnySheetHeaderDirective,
|
|
17
|
+
SnySheetTitleDirective,
|
|
18
|
+
SnySheetDescriptionDirective,
|
|
19
|
+
SnySheetCloseDirective,
|
|
20
|
+
SnyButtonDirective,
|
|
21
|
+
],
|
|
22
|
+
template: `
|
|
23
|
+
<div>
|
|
24
|
+
<div snySheetHeader>
|
|
25
|
+
<h2 snySheetTitle>Test Sheet</h2>
|
|
26
|
+
<p snySheetDescription>A test sheet.</p>
|
|
27
|
+
</div>
|
|
28
|
+
<button snySheetClose aria-label="Close">X</button>
|
|
29
|
+
<button snyBtn (click)="dialogRef.close('done')">Done</button>
|
|
30
|
+
</div>
|
|
31
|
+
`,
|
|
32
|
+
})
|
|
33
|
+
class TestSheetComponent {
|
|
34
|
+
readonly dialogRef = inject(DialogRef);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('SnySheetService', () => {
|
|
38
|
+
let service: SnySheetService;
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
await TestBed.configureTestingModule({
|
|
42
|
+
imports: [DialogModule],
|
|
43
|
+
}).compileComponents();
|
|
44
|
+
|
|
45
|
+
service = TestBed.inject(SnySheetService);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should be created', () => {
|
|
49
|
+
expect(service).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should open a sheet', () => {
|
|
53
|
+
const ref = service.open(TestSheetComponent, { side: 'right' });
|
|
54
|
+
expect(ref).toBeTruthy();
|
|
55
|
+
ref.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should open from different sides', () => {
|
|
59
|
+
const refLeft = service.open(TestSheetComponent, { side: 'left' });
|
|
60
|
+
expect(refLeft).toBeTruthy();
|
|
61
|
+
refLeft.close();
|
|
62
|
+
|
|
63
|
+
const refTop = service.open(TestSheetComponent, { side: 'top' });
|
|
64
|
+
expect(refTop).toBeTruthy();
|
|
65
|
+
refTop.close();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
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: '[snySheetHeader]',
|
|
7
|
+
standalone: true,
|
|
8
|
+
host: { '[class]': 'computedClass()' },
|
|
9
|
+
})
|
|
10
|
+
export class SnySheetHeaderDirective {
|
|
11
|
+
readonly class = input<string>('');
|
|
12
|
+
protected readonly computedClass = computed(() =>
|
|
13
|
+
cn('flex flex-col space-y-2', this.class())
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Directive({
|
|
18
|
+
selector: '[snySheetTitle]',
|
|
19
|
+
standalone: true,
|
|
20
|
+
host: { '[class]': 'computedClass()' },
|
|
21
|
+
})
|
|
22
|
+
export class SnySheetTitleDirective {
|
|
23
|
+
readonly class = input<string>('');
|
|
24
|
+
protected readonly computedClass = computed(() =>
|
|
25
|
+
cn('text-lg font-semibold text-foreground', this.class())
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Directive({
|
|
30
|
+
selector: '[snySheetDescription]',
|
|
31
|
+
standalone: true,
|
|
32
|
+
host: { '[class]': 'computedClass()' },
|
|
33
|
+
})
|
|
34
|
+
export class SnySheetDescriptionDirective {
|
|
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: '[snySheetContent]',
|
|
43
|
+
standalone: true,
|
|
44
|
+
host: { '[class]': 'computedClass()' },
|
|
45
|
+
})
|
|
46
|
+
export class SnySheetContentDirective {
|
|
47
|
+
readonly class = input<string>('');
|
|
48
|
+
protected readonly computedClass = computed(() =>
|
|
49
|
+
cn('py-4', this.class())
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Directive({
|
|
54
|
+
selector: '[snySheetClose]',
|
|
55
|
+
standalone: true,
|
|
56
|
+
host: {
|
|
57
|
+
'[class]': 'computedClass()',
|
|
58
|
+
'(click)': 'onClick()',
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
export class SnySheetCloseDirective {
|
|
62
|
+
readonly class = input<string>('');
|
|
63
|
+
protected readonly computedClass = computed(() =>
|
|
64
|
+
cn(
|
|
65
|
+
'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',
|
|
66
|
+
this.class()
|
|
67
|
+
)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
private readonly dialogRef = inject(DialogRef, { optional: true });
|
|
71
|
+
|
|
72
|
+
onClick(): void {
|
|
73
|
+
this.dialogRef?.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Injectable, inject, InjectionToken, Injector } from '@angular/core';
|
|
2
|
+
import { Dialog, DialogRef as CdkDialogRef } from '@angular/cdk/dialog';
|
|
3
|
+
import { type ComponentType, createGlobalPositionStrategy, type GlobalPositionStrategy } from '@angular/cdk/overlay';
|
|
4
|
+
import { SnySheetRef } from './sheet-ref';
|
|
5
|
+
import { DEFAULT_SHEET_CONFIG, SHEET_PANEL_CLASS, type SheetSide, type SnySheetConfig } from './sheet.types';
|
|
6
|
+
|
|
7
|
+
export const SNY_SHEET_DATA = new InjectionToken<unknown>('SNY_SHEET_DATA');
|
|
8
|
+
|
|
9
|
+
interface SheetOverlayConfig {
|
|
10
|
+
positionStrategy: GlobalPositionStrategy;
|
|
11
|
+
width?: string;
|
|
12
|
+
maxWidth?: string;
|
|
13
|
+
height?: string;
|
|
14
|
+
maxHeight?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Injectable({ providedIn: 'root' })
|
|
18
|
+
export class SnySheetService {
|
|
19
|
+
private readonly cdkDialog = inject(Dialog);
|
|
20
|
+
private readonly injector = inject(Injector);
|
|
21
|
+
|
|
22
|
+
open<T, R = unknown>(component: ComponentType<T>, config: SnySheetConfig = {}): SnySheetRef<R> {
|
|
23
|
+
const merged = { ...DEFAULT_SHEET_CONFIG, ...config };
|
|
24
|
+
const side = merged.side ?? 'right';
|
|
25
|
+
const disableClose = !merged.closeOnBackdrop || !merged.closeOnEsc;
|
|
26
|
+
const overlay = this._getOverlayConfig(side);
|
|
27
|
+
|
|
28
|
+
const cdkRef: CdkDialogRef<R, T> = this.cdkDialog.open(component, {
|
|
29
|
+
disableClose,
|
|
30
|
+
hasBackdrop: true,
|
|
31
|
+
backdropClass: 'sny-dialog-backdrop',
|
|
32
|
+
panelClass: ['sny-sheet-panel', SHEET_PANEL_CLASS[side]],
|
|
33
|
+
positionStrategy: overlay.positionStrategy,
|
|
34
|
+
width: overlay.width,
|
|
35
|
+
maxWidth: overlay.maxWidth,
|
|
36
|
+
height: overlay.height,
|
|
37
|
+
maxHeight: overlay.maxHeight,
|
|
38
|
+
ariaLabelledBy: merged.ariaLabelledBy,
|
|
39
|
+
ariaDescribedBy: merged.ariaDescribedBy,
|
|
40
|
+
data: merged.data,
|
|
41
|
+
providers: merged.data != null
|
|
42
|
+
? [{ provide: SNY_SHEET_DATA, useValue: merged.data }]
|
|
43
|
+
: [],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (disableClose) {
|
|
47
|
+
if (merged.closeOnBackdrop) {
|
|
48
|
+
const sub = cdkRef.backdropClick.subscribe(() => cdkRef.close());
|
|
49
|
+
cdkRef.closed.subscribe(() => sub.unsubscribe());
|
|
50
|
+
}
|
|
51
|
+
if (merged.closeOnEsc) {
|
|
52
|
+
const sub = cdkRef.keydownEvents.subscribe(event => {
|
|
53
|
+
if (event.key === 'Escape') cdkRef.close();
|
|
54
|
+
});
|
|
55
|
+
cdkRef.closed.subscribe(() => sub.unsubscribe());
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return new SnySheetRef<R>(cdkRef);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
closeAll(): void {
|
|
63
|
+
this.cdkDialog.closeAll();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private _getOverlayConfig(side: SheetSide): SheetOverlayConfig {
|
|
67
|
+
const strategy = createGlobalPositionStrategy(this.injector);
|
|
68
|
+
|
|
69
|
+
switch (side) {
|
|
70
|
+
case 'right':
|
|
71
|
+
return {
|
|
72
|
+
positionStrategy: strategy.top('0').right('0'),
|
|
73
|
+
width: '75%',
|
|
74
|
+
maxWidth: '24rem',
|
|
75
|
+
height: '100vh',
|
|
76
|
+
maxHeight: '100vh',
|
|
77
|
+
};
|
|
78
|
+
case 'left':
|
|
79
|
+
return {
|
|
80
|
+
positionStrategy: strategy.top('0').left('0'),
|
|
81
|
+
width: '75%',
|
|
82
|
+
maxWidth: '24rem',
|
|
83
|
+
height: '100vh',
|
|
84
|
+
maxHeight: '100vh',
|
|
85
|
+
};
|
|
86
|
+
case 'top':
|
|
87
|
+
return {
|
|
88
|
+
positionStrategy: strategy.top('0').centerHorizontally(),
|
|
89
|
+
width: '100vw',
|
|
90
|
+
maxWidth: '100vw',
|
|
91
|
+
};
|
|
92
|
+
case 'bottom':
|
|
93
|
+
return {
|
|
94
|
+
positionStrategy: strategy.bottom('0').centerHorizontally(),
|
|
95
|
+
width: '100vw',
|
|
96
|
+
maxWidth: '100vw',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type SheetSide = 'left' | 'right' | 'top' | 'bottom';
|
|
2
|
+
|
|
3
|
+
export interface SnySheetConfig {
|
|
4
|
+
side?: SheetSide;
|
|
5
|
+
closeOnBackdrop?: boolean;
|
|
6
|
+
closeOnEsc?: boolean;
|
|
7
|
+
data?: unknown;
|
|
8
|
+
ariaLabelledBy?: string;
|
|
9
|
+
ariaDescribedBy?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_SHEET_CONFIG: SnySheetConfig = {
|
|
13
|
+
side: 'right',
|
|
14
|
+
closeOnBackdrop: true,
|
|
15
|
+
closeOnEsc: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const SHEET_PANEL_CLASS: Record<SheetSide, string> = {
|
|
19
|
+
right: 'sny-sheet-right',
|
|
20
|
+
left: 'sny-sheet-left',
|
|
21
|
+
top: 'sny-sheet-top',
|
|
22
|
+
bottom: 'sny-sheet-bottom',
|
|
23
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnySkeletonDirective } from './skeleton.directive';
|
|
4
|
+
import type { SkeletonVariant, SkeletonSize } from './skeleton.variants';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnySkeletonDirective],
|
|
9
|
+
template: `<div snySkeleton [variant]="variant()" [size]="size()"></div>`,
|
|
10
|
+
})
|
|
11
|
+
class TestHostComponent {
|
|
12
|
+
variant = signal<SkeletonVariant>('line');
|
|
13
|
+
size = signal<SkeletonSize>('md');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('SnySkeletonDirective', () => {
|
|
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('div');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should apply default classes', () => {
|
|
31
|
+
expect(el.className).toContain('animate-pulse');
|
|
32
|
+
expect(el.className).toContain('bg-muted');
|
|
33
|
+
expect(el.className).toContain('rounded-sm');
|
|
34
|
+
expect(el.className).toContain('h-6');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should apply circular variant', () => {
|
|
38
|
+
fixture.componentInstance.variant.set('circular');
|
|
39
|
+
fixture.detectChanges();
|
|
40
|
+
expect(el.className).toContain('rounded-full');
|
|
41
|
+
expect(el.className).toContain('aspect-square');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should apply sm size', () => {
|
|
45
|
+
fixture.componentInstance.size.set('sm');
|
|
46
|
+
fixture.detectChanges();
|
|
47
|
+
expect(el.className).toContain('h-4');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should apply xl size', () => {
|
|
51
|
+
fixture.componentInstance.size.set('xl');
|
|
52
|
+
fixture.detectChanges();
|
|
53
|
+
expect(el.className).toContain('h-12');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Directive, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import { skeletonVariants, type SkeletonVariant, type SkeletonSize } from './skeleton.variants';
|
|
4
|
+
|
|
5
|
+
@Directive({
|
|
6
|
+
selector: '[snySkeleton]',
|
|
7
|
+
standalone: true,
|
|
8
|
+
host: { '[class]': 'computedClass()' },
|
|
9
|
+
})
|
|
10
|
+
export class SnySkeletonDirective {
|
|
11
|
+
readonly variant = input<SkeletonVariant>('line');
|
|
12
|
+
readonly size = input<SkeletonSize>('md');
|
|
13
|
+
readonly class = input<string>('');
|
|
14
|
+
|
|
15
|
+
protected readonly computedClass = computed(() =>
|
|
16
|
+
cn(skeletonVariants({ variant: this.variant(), size: this.size() }), this.class())
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const skeletonVariants = cva(
|
|
4
|
+
'animate-pulse bg-muted block',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
variant: {
|
|
8
|
+
line: 'rounded-sm',
|
|
9
|
+
circular: 'rounded-full aspect-square',
|
|
10
|
+
rounded: 'rounded-lg',
|
|
11
|
+
},
|
|
12
|
+
size: {
|
|
13
|
+
sm: 'h-4',
|
|
14
|
+
md: 'h-6',
|
|
15
|
+
lg: 'h-8',
|
|
16
|
+
xl: 'h-12',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
defaultVariants: {
|
|
20
|
+
variant: 'line',
|
|
21
|
+
size: 'md',
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export type SkeletonVariant = 'line' | 'circular' | 'rounded';
|
|
27
|
+
export type SkeletonSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnySliderComponent } from './slider.component';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [SnySliderComponent],
|
|
8
|
+
template: `<sny-slider [(value)]="value" [min]="min()" [max]="max()" [step]="step()" [disabled]="disabled()" />`,
|
|
9
|
+
})
|
|
10
|
+
class TestHostComponent {
|
|
11
|
+
value = signal(50);
|
|
12
|
+
min = signal(0);
|
|
13
|
+
max = signal(100);
|
|
14
|
+
step = signal(1);
|
|
15
|
+
disabled = signal(false);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('SnySliderComponent', () => {
|
|
19
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
20
|
+
let thumb: HTMLButtonElement;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
await TestBed.configureTestingModule({
|
|
24
|
+
imports: [TestHostComponent],
|
|
25
|
+
}).compileComponents();
|
|
26
|
+
|
|
27
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
28
|
+
fixture.detectChanges();
|
|
29
|
+
thumb = fixture.nativeElement.querySelector('button[role="slider"]');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should have slider role', () => {
|
|
33
|
+
expect(thumb.getAttribute('role')).toBe('slider');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should set aria values', () => {
|
|
37
|
+
expect(thumb.getAttribute('aria-valuemin')).toBe('0');
|
|
38
|
+
expect(thumb.getAttribute('aria-valuemax')).toBe('100');
|
|
39
|
+
expect(thumb.getAttribute('aria-valuenow')).toBe('50');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should increment with ArrowRight', () => {
|
|
43
|
+
const host = fixture.nativeElement.querySelector('sny-slider');
|
|
44
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
|
|
45
|
+
fixture.detectChanges();
|
|
46
|
+
expect(thumb.getAttribute('aria-valuenow')).toBe('51');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should decrement with ArrowLeft', () => {
|
|
50
|
+
const host = fixture.nativeElement.querySelector('sny-slider');
|
|
51
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
|
52
|
+
fixture.detectChanges();
|
|
53
|
+
expect(thumb.getAttribute('aria-valuenow')).toBe('49');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Component, computed, ElementRef, inject, input, model, OnDestroy, viewChild } from '@angular/core';
|
|
2
|
+
import { cn } from '../core/utils/cn';
|
|
3
|
+
import { sliderTrackVariants, sliderThumbSize, type SliderSize } from './slider.variants';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'sny-slider',
|
|
7
|
+
standalone: true,
|
|
8
|
+
host: {
|
|
9
|
+
class: 'block',
|
|
10
|
+
'(keydown)': 'onKeydown($event)',
|
|
11
|
+
},
|
|
12
|
+
template: `
|
|
13
|
+
<div
|
|
14
|
+
#trackEl
|
|
15
|
+
[class]="trackClass()"
|
|
16
|
+
(mousedown)="onTrackMousedown($event)"
|
|
17
|
+
(touchstart)="onTrackTouchstart($event)"
|
|
18
|
+
>
|
|
19
|
+
<div class="absolute h-full rounded-full bg-primary" [style.width.%]="percentage()"></div>
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
role="slider"
|
|
23
|
+
[attr.aria-valuemin]="min()"
|
|
24
|
+
[attr.aria-valuemax]="max()"
|
|
25
|
+
[attr.aria-valuenow]="value()"
|
|
26
|
+
[disabled]="disabled()"
|
|
27
|
+
[class]="thumbClass()"
|
|
28
|
+
[style.left.%]="percentage()"
|
|
29
|
+
tabindex="0"
|
|
30
|
+
></button>
|
|
31
|
+
</div>
|
|
32
|
+
`,
|
|
33
|
+
})
|
|
34
|
+
export class SnySliderComponent implements OnDestroy {
|
|
35
|
+
readonly value = model(0);
|
|
36
|
+
readonly min = input(0);
|
|
37
|
+
readonly max = input(100);
|
|
38
|
+
readonly step = input(1);
|
|
39
|
+
readonly disabled = input(false);
|
|
40
|
+
readonly size = input<SliderSize>('md');
|
|
41
|
+
readonly class = input<string>('');
|
|
42
|
+
|
|
43
|
+
private readonly trackRef = viewChild<ElementRef<HTMLDivElement>>('trackEl');
|
|
44
|
+
private moveHandler: ((e: MouseEvent | TouchEvent) => void) | null = null;
|
|
45
|
+
private upHandler: (() => void) | null = null;
|
|
46
|
+
|
|
47
|
+
protected readonly percentage = computed(() => {
|
|
48
|
+
const range = this.max() - this.min();
|
|
49
|
+
if (range <= 0) return 0;
|
|
50
|
+
return ((this.value() - this.min()) / range) * 100;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
protected readonly trackClass = computed(() =>
|
|
54
|
+
cn(sliderTrackVariants({ size: this.size() }), this.disabled() && 'opacity-50 cursor-not-allowed', this.class())
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
protected readonly thumbClass = computed(() =>
|
|
58
|
+
cn(
|
|
59
|
+
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 block rounded-full border-2 border-primary bg-background shadow ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
60
|
+
sliderThumbSize[this.size()]
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
private updateValueFromPosition(clientX: number): void {
|
|
65
|
+
if (this.disabled()) return;
|
|
66
|
+
const track = this.trackRef()?.nativeElement;
|
|
67
|
+
if (!track) return;
|
|
68
|
+
const rect = track.getBoundingClientRect();
|
|
69
|
+
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
70
|
+
const raw = this.min() + percent * (this.max() - this.min());
|
|
71
|
+
const stepped = Math.round(raw / this.step()) * this.step();
|
|
72
|
+
this.value.set(Math.max(this.min(), Math.min(this.max(), stepped)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
onTrackMousedown(event: MouseEvent): void {
|
|
76
|
+
if (this.disabled()) return;
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
this.updateValueFromPosition(event.clientX);
|
|
79
|
+
this.moveHandler = (e: MouseEvent | TouchEvent) => {
|
|
80
|
+
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
|
|
81
|
+
this.updateValueFromPosition(clientX);
|
|
82
|
+
};
|
|
83
|
+
this.upHandler = () => this.removeListeners();
|
|
84
|
+
document.addEventListener('mousemove', this.moveHandler as EventListener);
|
|
85
|
+
document.addEventListener('mouseup', this.upHandler);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
onTrackTouchstart(event: TouchEvent): void {
|
|
89
|
+
if (this.disabled()) return;
|
|
90
|
+
this.updateValueFromPosition(event.touches[0].clientX);
|
|
91
|
+
this.moveHandler = (e: MouseEvent | TouchEvent) => {
|
|
92
|
+
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
|
|
93
|
+
this.updateValueFromPosition(clientX);
|
|
94
|
+
};
|
|
95
|
+
this.upHandler = () => this.removeListeners();
|
|
96
|
+
document.addEventListener('touchmove', this.moveHandler as EventListener, { passive: true });
|
|
97
|
+
document.addEventListener('touchend', this.upHandler);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
onKeydown(event: KeyboardEvent): void {
|
|
101
|
+
if (this.disabled()) return;
|
|
102
|
+
const step = this.step();
|
|
103
|
+
switch (event.key) {
|
|
104
|
+
case 'ArrowRight':
|
|
105
|
+
case 'ArrowUp':
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
this.value.set(Math.min(this.max(), this.value() + step));
|
|
108
|
+
break;
|
|
109
|
+
case 'ArrowLeft':
|
|
110
|
+
case 'ArrowDown':
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
this.value.set(Math.max(this.min(), this.value() - step));
|
|
113
|
+
break;
|
|
114
|
+
case 'Home':
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
this.value.set(this.min());
|
|
117
|
+
break;
|
|
118
|
+
case 'End':
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
this.value.set(this.max());
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private removeListeners(): void {
|
|
126
|
+
if (this.moveHandler) {
|
|
127
|
+
document.removeEventListener('mousemove', this.moveHandler as EventListener);
|
|
128
|
+
document.removeEventListener('touchmove', this.moveHandler as EventListener);
|
|
129
|
+
this.moveHandler = null;
|
|
130
|
+
}
|
|
131
|
+
if (this.upHandler) {
|
|
132
|
+
document.removeEventListener('mouseup', this.upHandler);
|
|
133
|
+
document.removeEventListener('touchend', this.upHandler);
|
|
134
|
+
this.upHandler = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
ngOnDestroy(): void {
|
|
139
|
+
this.removeListeners();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const sliderTrackVariants = cva(
|
|
4
|
+
'relative w-full rounded-full bg-secondary cursor-pointer',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: 'h-1.5',
|
|
9
|
+
md: 'h-2',
|
|
10
|
+
lg: 'h-3',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: {
|
|
14
|
+
size: 'md',
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export const sliderThumbSize: Record<string, string> = {
|
|
20
|
+
sm: 'h-4 w-4',
|
|
21
|
+
md: 'h-5 w-5',
|
|
22
|
+
lg: 'h-6 w-6',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SliderSize = 'sm' | 'md' | 'lg';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnySwitchComponent } from './switch.component';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [SnySwitchComponent],
|
|
8
|
+
template: `<sny-switch [(checked)]="checked" [disabled]="disabled()" />`,
|
|
9
|
+
})
|
|
10
|
+
class TestHostComponent {
|
|
11
|
+
checked = signal(false);
|
|
12
|
+
disabled = signal(false);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('SnySwitchComponent', () => {
|
|
16
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
17
|
+
let button: HTMLButtonElement;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
await TestBed.configureTestingModule({
|
|
21
|
+
imports: [TestHostComponent],
|
|
22
|
+
}).compileComponents();
|
|
23
|
+
|
|
24
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
25
|
+
fixture.detectChanges();
|
|
26
|
+
button = fixture.nativeElement.querySelector('button');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should have switch role', () => {
|
|
30
|
+
expect(button.getAttribute('role')).toBe('switch');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should be unchecked by default', () => {
|
|
34
|
+
expect(button.getAttribute('aria-checked')).toBe('false');
|
|
35
|
+
expect(button.className).toContain('bg-input');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should toggle on click', () => {
|
|
39
|
+
button.click();
|
|
40
|
+
fixture.detectChanges();
|
|
41
|
+
expect(button.getAttribute('aria-checked')).toBe('true');
|
|
42
|
+
expect(button.className).toContain('bg-primary');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should not toggle when disabled', () => {
|
|
46
|
+
fixture.componentInstance.disabled.set(true);
|
|
47
|
+
fixture.detectChanges();
|
|
48
|
+
expect(button.disabled).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|