@sonny-ui/core 0.1.0-alpha.16 → 0.1.0-alpha.18
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 +109 -55
- package/fesm2022/sonny-ui-core.mjs +617 -178
- package/fesm2022/sonny-ui-core.mjs.map +1 -1
- package/package.json +1 -1
- package/src/lib/accordion/accordion.directives.ts +0 -4
- package/src/lib/alert/alert.directives.ts +0 -3
- package/src/lib/avatar/avatar.component.ts +0 -1
- package/src/lib/avatar-group/avatar-group.component.spec.ts +74 -0
- package/src/lib/avatar-group/avatar-group.component.ts +88 -0
- package/src/lib/avatar-group/index.ts +1 -0
- package/src/lib/badge/badge.directive.ts +0 -1
- package/src/lib/breadcrumb/breadcrumb.directives.ts +0 -6
- package/src/lib/button/button.directive.ts +0 -1
- package/src/lib/button-group/button-group.directive.ts +0 -1
- package/src/lib/calendar/calendar.component.ts +0 -1
- package/src/lib/card/card.directives.ts +0 -6
- package/src/lib/carousel/carousel.directives.ts +0 -5
- package/src/lib/chat-bubble/chat-bubble.directives.ts +0 -6
- package/src/lib/checkbox/checkbox.directive.ts +0 -1
- package/src/lib/color-picker/color-picker.component.ts +5 -5
- package/src/lib/combobox/combobox.component.ts +1 -3
- package/src/lib/command-palette/command-palette.component.ts +0 -1
- package/src/lib/data-table/data-table.component.ts +0 -1
- package/src/lib/data-table/data-table.directives.ts +0 -4
- package/src/lib/date-picker/date-picker.component.ts +5 -5
- package/src/lib/date-range-picker/date-range-picker.component.ts +5 -5
- package/src/lib/diff/diff.component.ts +0 -1
- package/src/lib/divider/divider.component.ts +0 -1
- package/src/lib/dock/dock.directives.ts +0 -2
- package/src/lib/drawer/drawer.directives.ts +0 -3
- package/src/lib/dropdown/dropdown.directives.ts +3 -10
- package/src/lib/fab/fab.directives.ts +0 -3
- package/src/lib/fieldset/fieldset.directives.ts +0 -3
- package/src/lib/file-input/file-input.component.ts +0 -1
- package/src/lib/indicator/indicator.directives.ts +0 -2
- package/src/lib/input/input.directive.ts +0 -1
- package/src/lib/input/label.directive.ts +0 -1
- package/src/lib/kbd/kbd.directive.ts +0 -1
- package/src/lib/link/link.directive.ts +0 -1
- package/src/lib/list/list.directives.ts +0 -5
- package/src/lib/loader/loader.component.ts +0 -1
- package/src/lib/modal/dialog.directives.ts +0 -6
- package/src/lib/navbar/navbar.directives.ts +0 -4
- package/src/lib/number-input/index.ts +2 -0
- package/src/lib/number-input/number-input.component.spec.ts +151 -0
- package/src/lib/number-input/number-input.component.ts +152 -0
- package/src/lib/number-input/number-input.variants.ts +17 -0
- package/src/lib/otp-input/otp-input.component.ts +0 -1
- package/src/lib/pagination/pagination.component.ts +0 -1
- package/src/lib/popover/index.ts +6 -0
- package/src/lib/popover/popover.directives.spec.ts +147 -0
- package/src/lib/popover/popover.directives.ts +151 -0
- package/src/lib/progress/progress.component.ts +0 -1
- package/src/lib/radial-progress/radial-progress.component.ts +0 -1
- package/src/lib/radio/radio.directive.ts +0 -1
- package/src/lib/rating/rating.component.ts +0 -1
- package/src/lib/select/select.component.ts +1 -3
- package/src/lib/sheet/sheet.directives.ts +0 -5
- package/src/lib/skeleton/skeleton.directive.ts +0 -1
- package/src/lib/slider/slider.component.ts +0 -1
- package/src/lib/stat/stat.directives.ts +0 -5
- package/src/lib/status/status.directive.ts +0 -1
- package/src/lib/steps/steps.directives.ts +0 -2
- package/src/lib/switch/switch.component.ts +0 -1
- package/src/lib/table/table.directives.ts +0 -8
- package/src/lib/tabs/tabs.directives.ts +0 -4
- package/src/lib/tag-input/index.ts +2 -0
- package/src/lib/tag-input/tag-input.component.spec.ts +190 -0
- package/src/lib/tag-input/tag-input.component.ts +172 -0
- package/src/lib/tag-input/tag-input.variants.ts +31 -0
- package/src/lib/textarea/textarea.directive.ts +0 -1
- package/src/lib/timeline/timeline.directives.ts +0 -5
- package/src/lib/toast/toaster.component.ts +0 -1
- package/src/lib/toggle/toggle.directive.ts +0 -1
- package/src/lib/tooltip/tooltip.directive.ts +0 -1
- package/src/lib/validator/validator.directives.ts +0 -2
- package/types/sonny-ui-core.d.ts +147 -2
|
@@ -5,7 +5,6 @@ export type DockPosition = 'bottom' | 'top';
|
|
|
5
5
|
|
|
6
6
|
@Directive({
|
|
7
7
|
selector: '[snyDock]',
|
|
8
|
-
standalone: true,
|
|
9
8
|
host: {
|
|
10
9
|
'role': 'toolbar',
|
|
11
10
|
'aria-label': 'Dock',
|
|
@@ -63,7 +62,6 @@ export class SnyDockDirective {
|
|
|
63
62
|
|
|
64
63
|
@Directive({
|
|
65
64
|
selector: '[snyDockItem]',
|
|
66
|
-
standalone: true,
|
|
67
65
|
host: {
|
|
68
66
|
'[class]': 'computedClass()',
|
|
69
67
|
'[attr.tabindex]': 'active() ? 0 : -1',
|
|
@@ -5,7 +5,6 @@ export const SNY_DRAWER = new InjectionToken<SnyDrawerLayoutComponent>('SnyDrawe
|
|
|
5
5
|
|
|
6
6
|
@Component({
|
|
7
7
|
selector: '[snyDrawerLayout]',
|
|
8
|
-
standalone: true,
|
|
9
8
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
10
9
|
exportAs: 'snyDrawerLayout',
|
|
11
10
|
providers: [{ provide: SNY_DRAWER, useExisting: SnyDrawerLayoutComponent }],
|
|
@@ -41,7 +40,6 @@ export const SnyDrawerLayoutDirective = SnyDrawerLayoutComponent;
|
|
|
41
40
|
|
|
42
41
|
@Directive({
|
|
43
42
|
selector: '[snyDrawerContent]',
|
|
44
|
-
standalone: true,
|
|
45
43
|
host: {
|
|
46
44
|
'[class]': 'computedClass()',
|
|
47
45
|
},
|
|
@@ -57,7 +55,6 @@ export type DrawerSide = 'left' | 'right';
|
|
|
57
55
|
|
|
58
56
|
@Directive({
|
|
59
57
|
selector: '[snyDrawerSide]',
|
|
60
|
-
standalone: true,
|
|
61
58
|
host: {
|
|
62
59
|
'role': 'navigation',
|
|
63
60
|
'aria-label': 'Sidebar navigation',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Directive, ElementRef,
|
|
1
|
+
import { Directive, ElementRef, InjectionToken, computed, inject, input, signal } from '@angular/core';
|
|
2
2
|
import { cn } from '../core/utils/cn';
|
|
3
3
|
import {
|
|
4
4
|
dropdownContentVariants,
|
|
@@ -10,11 +10,12 @@ export const SNY_DROPDOWN = new InjectionToken<SnyDropdownDirective>('SnyDropdow
|
|
|
10
10
|
|
|
11
11
|
@Directive({
|
|
12
12
|
selector: '[snyDropdown]',
|
|
13
|
-
standalone: true,
|
|
14
13
|
exportAs: 'snyDropdown',
|
|
15
14
|
providers: [{ provide: SNY_DROPDOWN, useExisting: SnyDropdownDirective }],
|
|
16
15
|
host: {
|
|
17
16
|
'[class]': '"relative inline-block"',
|
|
17
|
+
'(document:click)': 'onDocumentClick($event)',
|
|
18
|
+
'(keydown.escape)': 'onEscape()',
|
|
18
19
|
},
|
|
19
20
|
})
|
|
20
21
|
export class SnyDropdownDirective {
|
|
@@ -25,14 +26,12 @@ export class SnyDropdownDirective {
|
|
|
25
26
|
open(): void { this.isOpen.set(true); }
|
|
26
27
|
close(): void { this.isOpen.set(false); }
|
|
27
28
|
|
|
28
|
-
@HostListener('document:click', ['$event'])
|
|
29
29
|
onDocumentClick(event: MouseEvent): void {
|
|
30
30
|
if (!this.elementRef.nativeElement.contains(event.target)) {
|
|
31
31
|
this.close();
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
@HostListener('keydown.escape')
|
|
36
35
|
onEscape(): void {
|
|
37
36
|
this.close();
|
|
38
37
|
}
|
|
@@ -40,7 +39,6 @@ export class SnyDropdownDirective {
|
|
|
40
39
|
|
|
41
40
|
@Directive({
|
|
42
41
|
selector: '[snyDropdownTrigger]',
|
|
43
|
-
standalone: true,
|
|
44
42
|
host: {
|
|
45
43
|
'(click)': 'dropdown.toggle()',
|
|
46
44
|
'[attr.aria-expanded]': 'dropdown.isOpen()',
|
|
@@ -53,7 +51,6 @@ export class SnyDropdownTriggerDirective {
|
|
|
53
51
|
|
|
54
52
|
@Directive({
|
|
55
53
|
selector: '[snyDropdownContent]',
|
|
56
|
-
standalone: true,
|
|
57
54
|
host: {
|
|
58
55
|
'role': 'menu',
|
|
59
56
|
'[class]': 'computedClass()',
|
|
@@ -75,7 +72,6 @@ export class SnyDropdownContentDirective {
|
|
|
75
72
|
|
|
76
73
|
@Directive({
|
|
77
74
|
selector: '[snyMenuContent]',
|
|
78
|
-
standalone: true,
|
|
79
75
|
host: {
|
|
80
76
|
'[class]': 'computedClass()',
|
|
81
77
|
},
|
|
@@ -90,7 +86,6 @@ export class SnyMenuContentDirective {
|
|
|
90
86
|
|
|
91
87
|
@Directive({
|
|
92
88
|
selector: '[snyMenuItem]',
|
|
93
|
-
standalone: true,
|
|
94
89
|
host: {
|
|
95
90
|
'role': 'menuitem',
|
|
96
91
|
'[class]': 'computedClass()',
|
|
@@ -113,7 +108,6 @@ export class SnyMenuItemDirective {
|
|
|
113
108
|
|
|
114
109
|
@Directive({
|
|
115
110
|
selector: '[snyMenuSeparator]',
|
|
116
|
-
standalone: true,
|
|
117
111
|
host: {
|
|
118
112
|
'role': 'separator',
|
|
119
113
|
'[class]': 'computedClass()',
|
|
@@ -129,7 +123,6 @@ export class SnyMenuSeparatorDirective {
|
|
|
129
123
|
|
|
130
124
|
@Directive({
|
|
131
125
|
selector: '[snyMenuLabel]',
|
|
132
|
-
standalone: true,
|
|
133
126
|
host: {
|
|
134
127
|
'[class]': 'computedClass()',
|
|
135
128
|
},
|
|
@@ -14,7 +14,6 @@ const positionMap: Record<FabPosition, string> = {
|
|
|
14
14
|
|
|
15
15
|
@Directive({
|
|
16
16
|
selector: '[snyFab]',
|
|
17
|
-
standalone: true,
|
|
18
17
|
exportAs: 'snyFab',
|
|
19
18
|
providers: [{ provide: SNY_FAB, useExisting: SnyFabDirective }],
|
|
20
19
|
host: { '[class]': 'computedClass()' },
|
|
@@ -36,7 +35,6 @@ export class SnyFabDirective {
|
|
|
36
35
|
|
|
37
36
|
@Directive({
|
|
38
37
|
selector: '[snyFabTrigger]',
|
|
39
|
-
standalone: true,
|
|
40
38
|
host: {
|
|
41
39
|
'(click)': 'fab.toggle()',
|
|
42
40
|
'[attr.aria-expanded]': 'fab.isOpen()',
|
|
@@ -59,7 +57,6 @@ export class SnyFabTriggerDirective {
|
|
|
59
57
|
|
|
60
58
|
@Directive({
|
|
61
59
|
selector: '[snyFabAction]',
|
|
62
|
-
standalone: true,
|
|
63
60
|
host: {
|
|
64
61
|
'role': 'menuitem',
|
|
65
62
|
'[attr.aria-label]': 'ariaLabel() || null',
|
|
@@ -4,7 +4,6 @@ import { fieldsetVariants, type FieldsetVariant } from './fieldset.variants';
|
|
|
4
4
|
|
|
5
5
|
@Directive({
|
|
6
6
|
selector: 'fieldset[snyFieldset]',
|
|
7
|
-
standalone: true,
|
|
8
7
|
host: {
|
|
9
8
|
'[class]': 'computedClass()',
|
|
10
9
|
'[attr.disabled]': 'disabled() || null',
|
|
@@ -23,7 +22,6 @@ export class SnyFieldsetDirective {
|
|
|
23
22
|
|
|
24
23
|
@Directive({
|
|
25
24
|
selector: 'legend[snyFieldsetLegend]',
|
|
26
|
-
standalone: true,
|
|
27
25
|
host: {
|
|
28
26
|
'[class]': 'computedClass()',
|
|
29
27
|
},
|
|
@@ -38,7 +36,6 @@ export class SnyFieldsetLegendDirective {
|
|
|
38
36
|
|
|
39
37
|
@Directive({
|
|
40
38
|
selector: '[snyFieldsetContent]',
|
|
41
|
-
standalone: true,
|
|
42
39
|
host: {
|
|
43
40
|
'[class]': 'computedClass()',
|
|
44
41
|
},
|
|
@@ -26,7 +26,6 @@ const variantMap: Record<IndicatorVariant, string> = {
|
|
|
26
26
|
|
|
27
27
|
@Directive({
|
|
28
28
|
selector: '[snyIndicator]',
|
|
29
|
-
standalone: true,
|
|
30
29
|
host: { '[class]': 'computedClass()' },
|
|
31
30
|
})
|
|
32
31
|
export class SnyIndicatorDirective {
|
|
@@ -38,7 +37,6 @@ export class SnyIndicatorDirective {
|
|
|
38
37
|
|
|
39
38
|
@Directive({
|
|
40
39
|
selector: '[snyIndicatorBadge]',
|
|
41
|
-
standalone: true,
|
|
42
40
|
host: {
|
|
43
41
|
'[class]': 'computedClass()',
|
|
44
42
|
'[attr.aria-label]': 'ariaLabel() || null',
|
|
@@ -5,7 +5,6 @@ export type ListVariant = 'default' | 'bordered' | 'hover';
|
|
|
5
5
|
|
|
6
6
|
@Directive({
|
|
7
7
|
selector: '[snyList]',
|
|
8
|
-
standalone: true,
|
|
9
8
|
host: {
|
|
10
9
|
'role': 'list',
|
|
11
10
|
'[class]': 'computedClass()',
|
|
@@ -27,7 +26,6 @@ export class SnyListDirective {
|
|
|
27
26
|
|
|
28
27
|
@Directive({
|
|
29
28
|
selector: '[snyListItem]',
|
|
30
|
-
standalone: true,
|
|
31
29
|
host: {
|
|
32
30
|
'role': 'listitem',
|
|
33
31
|
'[class]': 'computedClass()',
|
|
@@ -51,7 +49,6 @@ export class SnyListItemDirective {
|
|
|
51
49
|
|
|
52
50
|
@Directive({
|
|
53
51
|
selector: '[snyListItemIcon]',
|
|
54
|
-
standalone: true,
|
|
55
52
|
host: { '[class]': 'computedClass()' },
|
|
56
53
|
})
|
|
57
54
|
export class SnyListItemIconDirective {
|
|
@@ -63,7 +60,6 @@ export class SnyListItemIconDirective {
|
|
|
63
60
|
|
|
64
61
|
@Directive({
|
|
65
62
|
selector: '[snyListItemContent]',
|
|
66
|
-
standalone: true,
|
|
67
63
|
host: { '[class]': 'computedClass()' },
|
|
68
64
|
})
|
|
69
65
|
export class SnyListItemContentDirective {
|
|
@@ -75,7 +71,6 @@ export class SnyListItemContentDirective {
|
|
|
75
71
|
|
|
76
72
|
@Directive({
|
|
77
73
|
selector: '[snyListItemAction]',
|
|
78
|
-
standalone: true,
|
|
79
74
|
host: { '[class]': 'computedClass()' },
|
|
80
75
|
})
|
|
81
76
|
export class SnyListItemActionDirective {
|
|
@@ -4,7 +4,6 @@ import { cn } from '../core/utils/cn';
|
|
|
4
4
|
|
|
5
5
|
@Directive({
|
|
6
6
|
selector: '[snyDialogHeader]',
|
|
7
|
-
standalone: true,
|
|
8
7
|
host: { '[class]': 'computedClass()' },
|
|
9
8
|
})
|
|
10
9
|
export class SnyDialogHeaderDirective {
|
|
@@ -16,7 +15,6 @@ export class SnyDialogHeaderDirective {
|
|
|
16
15
|
|
|
17
16
|
@Directive({
|
|
18
17
|
selector: '[snyDialogTitle]',
|
|
19
|
-
standalone: true,
|
|
20
18
|
host: { '[class]': 'computedClass()' },
|
|
21
19
|
})
|
|
22
20
|
export class SnyDialogTitleDirective {
|
|
@@ -28,7 +26,6 @@ export class SnyDialogTitleDirective {
|
|
|
28
26
|
|
|
29
27
|
@Directive({
|
|
30
28
|
selector: '[snyDialogDescription]',
|
|
31
|
-
standalone: true,
|
|
32
29
|
host: { '[class]': 'computedClass()' },
|
|
33
30
|
})
|
|
34
31
|
export class SnyDialogDescriptionDirective {
|
|
@@ -40,7 +37,6 @@ export class SnyDialogDescriptionDirective {
|
|
|
40
37
|
|
|
41
38
|
@Directive({
|
|
42
39
|
selector: '[snyDialogContent]',
|
|
43
|
-
standalone: true,
|
|
44
40
|
host: { '[class]': 'computedClass()' },
|
|
45
41
|
})
|
|
46
42
|
export class SnyDialogContentDirective {
|
|
@@ -55,7 +51,6 @@ export class SnyDialogContentDirective {
|
|
|
55
51
|
|
|
56
52
|
@Directive({
|
|
57
53
|
selector: '[snyDialogFooter]',
|
|
58
|
-
standalone: true,
|
|
59
54
|
host: { '[class]': 'computedClass()' },
|
|
60
55
|
})
|
|
61
56
|
export class SnyDialogFooterDirective {
|
|
@@ -67,7 +62,6 @@ export class SnyDialogFooterDirective {
|
|
|
67
62
|
|
|
68
63
|
@Directive({
|
|
69
64
|
selector: '[snyDialogClose]',
|
|
70
|
-
standalone: true,
|
|
71
65
|
host: {
|
|
72
66
|
'[class]': 'computedClass()',
|
|
73
67
|
'(click)': 'onClick()',
|
|
@@ -5,7 +5,6 @@ export type NavbarVariant = 'default' | 'bordered' | 'floating';
|
|
|
5
5
|
|
|
6
6
|
@Directive({
|
|
7
7
|
selector: '[snyNavbar]',
|
|
8
|
-
standalone: true,
|
|
9
8
|
host: {
|
|
10
9
|
'role': 'navigation',
|
|
11
10
|
'[attr.aria-label]': 'ariaLabel()',
|
|
@@ -32,7 +31,6 @@ export class SnyNavbarDirective {
|
|
|
32
31
|
|
|
33
32
|
@Directive({
|
|
34
33
|
selector: '[snyNavbarBrand]',
|
|
35
|
-
standalone: true,
|
|
36
34
|
host: { '[class]': 'computedClass()' },
|
|
37
35
|
})
|
|
38
36
|
export class SnyNavbarBrandDirective {
|
|
@@ -42,7 +40,6 @@ export class SnyNavbarBrandDirective {
|
|
|
42
40
|
|
|
43
41
|
@Directive({
|
|
44
42
|
selector: '[snyNavbarContent]',
|
|
45
|
-
standalone: true,
|
|
46
43
|
host: { '[class]': 'computedClass()' },
|
|
47
44
|
})
|
|
48
45
|
export class SnyNavbarContentDirective {
|
|
@@ -52,7 +49,6 @@ export class SnyNavbarContentDirective {
|
|
|
52
49
|
|
|
53
50
|
@Directive({
|
|
54
51
|
selector: '[snyNavbarEnd]',
|
|
55
|
-
standalone: true,
|
|
56
52
|
host: { '[class]': 'computedClass()' },
|
|
57
53
|
})
|
|
58
54
|
export class SnyNavbarEndDirective {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
|
3
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
4
|
+
import { SnyNumberInputComponent } from './number-input.component';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyNumberInputComponent],
|
|
9
|
+
template: `
|
|
10
|
+
<sny-number-input
|
|
11
|
+
[(value)]="num"
|
|
12
|
+
[min]="min()"
|
|
13
|
+
[max]="max()"
|
|
14
|
+
[step]="step()"
|
|
15
|
+
[disabled]="disabled()"
|
|
16
|
+
/>
|
|
17
|
+
`,
|
|
18
|
+
})
|
|
19
|
+
class TestHost {
|
|
20
|
+
num = signal(5);
|
|
21
|
+
min = signal<number | null>(null);
|
|
22
|
+
max = signal<number | null>(null);
|
|
23
|
+
step = signal(1);
|
|
24
|
+
disabled = signal(false);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('SnyNumberInputComponent', () => {
|
|
28
|
+
let fixture: ComponentFixture<TestHost>;
|
|
29
|
+
let el: HTMLElement;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
|
|
33
|
+
fixture = TestBed.createComponent(TestHost);
|
|
34
|
+
fixture.detectChanges();
|
|
35
|
+
el = fixture.nativeElement;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function getInput(): HTMLInputElement {
|
|
39
|
+
return el.querySelector('input') as HTMLInputElement;
|
|
40
|
+
}
|
|
41
|
+
function getButtons(): HTMLButtonElement[] {
|
|
42
|
+
return Array.from(el.querySelectorAll('button'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it('should render with initial value', () => {
|
|
46
|
+
expect(getInput().value).toBe('5');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should increment on + click', () => {
|
|
50
|
+
getButtons()[1].click();
|
|
51
|
+
fixture.detectChanges();
|
|
52
|
+
expect(fixture.componentInstance.num()).toBe(6);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should decrement on - click', () => {
|
|
56
|
+
getButtons()[0].click();
|
|
57
|
+
fixture.detectChanges();
|
|
58
|
+
expect(fixture.componentInstance.num()).toBe(4);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should respect min', () => {
|
|
62
|
+
fixture.componentInstance.min.set(5);
|
|
63
|
+
fixture.detectChanges();
|
|
64
|
+
getButtons()[0].click();
|
|
65
|
+
fixture.detectChanges();
|
|
66
|
+
expect(fixture.componentInstance.num()).toBe(5);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should respect max', () => {
|
|
70
|
+
fixture.componentInstance.max.set(5);
|
|
71
|
+
fixture.detectChanges();
|
|
72
|
+
getButtons()[1].click();
|
|
73
|
+
fixture.detectChanges();
|
|
74
|
+
expect(fixture.componentInstance.num()).toBe(5);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should use step', () => {
|
|
78
|
+
fixture.componentInstance.step.set(10);
|
|
79
|
+
fixture.detectChanges();
|
|
80
|
+
getButtons()[1].click();
|
|
81
|
+
fixture.detectChanges();
|
|
82
|
+
expect(fixture.componentInstance.num()).toBe(15); // 5 + 10
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should handle manual input on blur', () => {
|
|
86
|
+
const input = getInput();
|
|
87
|
+
input.value = '42';
|
|
88
|
+
input.dispatchEvent(new Event('input'));
|
|
89
|
+
input.dispatchEvent(new Event('blur'));
|
|
90
|
+
fixture.detectChanges();
|
|
91
|
+
expect(fixture.componentInstance.num()).toBe(42);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should revert invalid input on blur', () => {
|
|
95
|
+
const input = getInput();
|
|
96
|
+
input.value = 'abc';
|
|
97
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
98
|
+
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
99
|
+
fixture.detectChanges();
|
|
100
|
+
// Value should remain unchanged at 5
|
|
101
|
+
expect(fixture.componentInstance.num()).toBe(5);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should handle ArrowUp/Down', () => {
|
|
105
|
+
const input = getInput();
|
|
106
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
|
107
|
+
fixture.detectChanges();
|
|
108
|
+
expect(fixture.componentInstance.num()).toBe(6);
|
|
109
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
110
|
+
fixture.detectChanges();
|
|
111
|
+
expect(fixture.componentInstance.num()).toBe(5);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should disable when disabled', () => {
|
|
115
|
+
fixture.componentInstance.disabled.set(true);
|
|
116
|
+
fixture.detectChanges();
|
|
117
|
+
expect(getInput().disabled).toBe(true);
|
|
118
|
+
expect(getButtons().every(b => b.disabled)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
@Component({
|
|
123
|
+
standalone: true,
|
|
124
|
+
imports: [ReactiveFormsModule, SnyNumberInputComponent],
|
|
125
|
+
template: `<sny-number-input [formControl]="ctrl" />`,
|
|
126
|
+
})
|
|
127
|
+
class ReactiveHost {
|
|
128
|
+
ctrl = new FormControl(10);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe('SnyNumberInputComponent — Reactive Forms', () => {
|
|
132
|
+
let fixture: ComponentFixture<ReactiveHost>;
|
|
133
|
+
|
|
134
|
+
beforeEach(async () => {
|
|
135
|
+
await TestBed.configureTestingModule({ imports: [ReactiveHost] }).compileComponents();
|
|
136
|
+
fixture = TestBed.createComponent(ReactiveHost);
|
|
137
|
+
fixture.detectChanges();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should display FormControl value', () => {
|
|
141
|
+
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
|
142
|
+
expect(input.value).toBe('10');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should update FormControl on increment', () => {
|
|
146
|
+
const buttons = fixture.nativeElement.querySelectorAll('button');
|
|
147
|
+
buttons[1].click();
|
|
148
|
+
fixture.detectChanges();
|
|
149
|
+
expect(fixture.componentInstance.ctrl.value).toBe(11);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
computed,
|
|
5
|
+
effect,
|
|
6
|
+
forwardRef,
|
|
7
|
+
input,
|
|
8
|
+
model,
|
|
9
|
+
signal,
|
|
10
|
+
untracked,
|
|
11
|
+
} from '@angular/core';
|
|
12
|
+
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
13
|
+
import { cn } from '../core/utils/cn';
|
|
14
|
+
import { numberInputVariants, type NumberInputSize } from './number-input.variants';
|
|
15
|
+
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'sny-number-input',
|
|
18
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
19
|
+
providers: [
|
|
20
|
+
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyNumberInputComponent), multi: true },
|
|
21
|
+
],
|
|
22
|
+
template: `
|
|
23
|
+
<div [class]="containerClass()">
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
class="px-2.5 hover:bg-accent transition-colors border-r border-border disabled:opacity-40 disabled:cursor-not-allowed"
|
|
27
|
+
[disabled]="isDisabled() || atMin()"
|
|
28
|
+
(click)="decrement()"
|
|
29
|
+
aria-label="Decrease"
|
|
30
|
+
>
|
|
31
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></svg>
|
|
32
|
+
</button>
|
|
33
|
+
<input
|
|
34
|
+
#inputEl
|
|
35
|
+
type="text"
|
|
36
|
+
inputmode="decimal"
|
|
37
|
+
class="flex-1 w-14 text-center outline-none bg-transparent font-medium"
|
|
38
|
+
[value]="inputValue()"
|
|
39
|
+
[disabled]="isDisabled()"
|
|
40
|
+
[placeholder]="placeholder()"
|
|
41
|
+
[attr.aria-label]="'Number input'"
|
|
42
|
+
[attr.aria-valuemin]="min() ?? null"
|
|
43
|
+
[attr.aria-valuemax]="max() ?? null"
|
|
44
|
+
[attr.aria-valuenow]="value()"
|
|
45
|
+
role="spinbutton"
|
|
46
|
+
(input)="onInput($event)"
|
|
47
|
+
(blur)="commitValue()"
|
|
48
|
+
(keydown)="onKeydown($event)"
|
|
49
|
+
/>
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
class="px-2.5 hover:bg-accent transition-colors border-l border-border disabled:opacity-40 disabled:cursor-not-allowed"
|
|
53
|
+
[disabled]="isDisabled() || atMax()"
|
|
54
|
+
(click)="increment()"
|
|
55
|
+
aria-label="Increase"
|
|
56
|
+
>
|
|
57
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
`,
|
|
61
|
+
})
|
|
62
|
+
export class SnyNumberInputComponent implements ControlValueAccessor {
|
|
63
|
+
readonly value = model(0);
|
|
64
|
+
readonly min = input<number | null>(null);
|
|
65
|
+
readonly max = input<number | null>(null);
|
|
66
|
+
readonly step = input(1);
|
|
67
|
+
readonly disabled = input(false);
|
|
68
|
+
readonly size = input<NumberInputSize>('md');
|
|
69
|
+
readonly placeholder = input('');
|
|
70
|
+
readonly class = input<string>('');
|
|
71
|
+
|
|
72
|
+
readonly inputValue = signal('0');
|
|
73
|
+
private readonly _disabledByCva = signal(false);
|
|
74
|
+
readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
|
|
75
|
+
|
|
76
|
+
readonly atMin = computed(() => this.min() !== null && this.value() <= this.min()!);
|
|
77
|
+
readonly atMax = computed(() => this.max() !== null && this.value() >= this.max()!);
|
|
78
|
+
|
|
79
|
+
readonly containerClass = computed(() =>
|
|
80
|
+
cn(numberInputVariants({ size: this.size() }), this.isDisabled() && 'opacity-50', this.class())
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
private _onChange: (value: number) => void = () => {};
|
|
84
|
+
private _onTouched: () => void = () => {};
|
|
85
|
+
|
|
86
|
+
constructor() {
|
|
87
|
+
effect(() => {
|
|
88
|
+
const val = this.value();
|
|
89
|
+
untracked(() => this.inputValue.set(String(val)));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
writeValue(val: number): void {
|
|
94
|
+
this.value.set(val ?? 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
registerOnChange(fn: (value: number) => void): void {
|
|
98
|
+
this._onChange = fn;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
registerOnTouched(fn: () => void): void {
|
|
102
|
+
this._onTouched = fn;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setDisabledState(isDisabled: boolean): void {
|
|
106
|
+
this._disabledByCva.set(isDisabled);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
increment(): void {
|
|
110
|
+
this.setValue(this.value() + this.step());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
decrement(): void {
|
|
114
|
+
this.setValue(this.value() - this.step());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
onInput(event: Event): void {
|
|
118
|
+
this.inputValue.set((event.target as HTMLInputElement).value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
commitValue(): void {
|
|
122
|
+
const parsed = parseFloat(this.inputValue());
|
|
123
|
+
if (isNaN(parsed)) {
|
|
124
|
+
this.inputValue.set(String(this.value()));
|
|
125
|
+
} else {
|
|
126
|
+
this.setValue(parsed);
|
|
127
|
+
}
|
|
128
|
+
this._onTouched();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
onKeydown(event: KeyboardEvent): void {
|
|
132
|
+
switch (event.key) {
|
|
133
|
+
case 'ArrowUp':
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
this.increment();
|
|
136
|
+
break;
|
|
137
|
+
case 'ArrowDown':
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
this.decrement();
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private setValue(raw: number): void {
|
|
145
|
+
let clamped = raw;
|
|
146
|
+
if (this.min() !== null) clamped = Math.max(this.min()!, clamped);
|
|
147
|
+
if (this.max() !== null) clamped = Math.min(this.max()!, clamped);
|
|
148
|
+
const rounded = parseFloat(clamped.toFixed(10));
|
|
149
|
+
this.value.set(rounded);
|
|
150
|
+
this._onChange(rounded);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const numberInputVariants = cva(
|
|
4
|
+
'inline-flex items-center border border-border rounded-md bg-background transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: 'h-9 text-xs',
|
|
9
|
+
md: 'h-10 text-sm',
|
|
10
|
+
lg: 'h-11 text-base',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: { size: 'md' },
|
|
14
|
+
}
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export type NumberInputSize = 'sm' | 'md' | 'lg';
|
|
@@ -20,7 +20,6 @@ import { otpCellVariants, type OtpInputSize, type OtpInputType } from './otp-inp
|
|
|
20
20
|
|
|
21
21
|
@Component({
|
|
22
22
|
selector: 'sny-otp-input',
|
|
23
|
-
standalone: true,
|
|
24
23
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
25
24
|
providers: [
|
|
26
25
|
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyOtpInputComponent), multi: true },
|