@sonny-ui/core 0.1.0-alpha.16 → 0.1.0-alpha.17
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 +599 -3
- package/fesm2022/sonny-ui-core.mjs.map +1 -1
- package/package.json +1 -1
- package/src/lib/avatar-group/avatar-group.component.spec.ts +74 -0
- package/src/lib/avatar-group/avatar-group.component.ts +89 -0
- package/src/lib/avatar-group/index.ts +1 -0
- package/src/lib/number-input/index.ts +2 -0
- package/src/lib/number-input/number-input.component.spec.ts +151 -0
- package/src/lib/number-input/number-input.component.ts +153 -0
- package/src/lib/number-input/number-input.variants.ts +17 -0
- package/src/lib/popover/index.ts +6 -0
- package/src/lib/popover/popover.directives.spec.ts +147 -0
- package/src/lib/popover/popover.directives.ts +155 -0
- package/src/lib/tag-input/index.ts +2 -0
- package/src/lib/tag-input/tag-input.component.spec.ts +190 -0
- package/src/lib/tag-input/tag-input.component.ts +173 -0
- package/src/lib/tag-input/tag-input.variants.ts +31 -0
- package/types/sonny-ui-core.d.ts +147 -2
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyAvatarGroupComponent, type AvatarGroupItem } from './avatar-group.component';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [SnyAvatarGroupComponent],
|
|
8
|
+
template: `<sny-avatar-group [items]="items()" [max]="max()" [size]="size()" />`,
|
|
9
|
+
})
|
|
10
|
+
class TestHost {
|
|
11
|
+
items = signal<AvatarGroupItem[]>([
|
|
12
|
+
{ src: 'a.jpg', alt: 'Alice' },
|
|
13
|
+
{ src: 'b.jpg', alt: 'Bob' },
|
|
14
|
+
{ src: 'c.jpg', alt: 'Carol' },
|
|
15
|
+
{ src: 'd.jpg', alt: 'David' },
|
|
16
|
+
{ src: 'e.jpg', alt: 'Eve' },
|
|
17
|
+
]);
|
|
18
|
+
max = signal(3);
|
|
19
|
+
size = signal<'sm' | 'md' | 'lg'>('md');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('SnyAvatarGroupComponent', () => {
|
|
23
|
+
let fixture: ComponentFixture<TestHost>;
|
|
24
|
+
let el: HTMLElement;
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
|
|
28
|
+
fixture = TestBed.createComponent(TestHost);
|
|
29
|
+
fixture.detectChanges();
|
|
30
|
+
el = fixture.nativeElement;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should render max avatars', () => {
|
|
34
|
+
const imgs = el.querySelectorAll('img');
|
|
35
|
+
expect(imgs.length).toBe(3);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should show overflow counter', () => {
|
|
39
|
+
const counter = el.querySelector('[title="2 more"]');
|
|
40
|
+
expect(counter).not.toBeNull();
|
|
41
|
+
expect(counter?.textContent).toContain('+2');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should not show counter when no overflow', () => {
|
|
45
|
+
fixture.componentInstance.max.set(5);
|
|
46
|
+
fixture.detectChanges();
|
|
47
|
+
const counter = el.querySelector('[title]');
|
|
48
|
+
expect(counter).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should render all when max >= items', () => {
|
|
52
|
+
fixture.componentInstance.max.set(10);
|
|
53
|
+
fixture.detectChanges();
|
|
54
|
+
const imgs = el.querySelectorAll('img');
|
|
55
|
+
expect(imgs.length).toBe(5);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should render fallback initials when no src', () => {
|
|
59
|
+
fixture.componentInstance.items.set([
|
|
60
|
+
{ fallback: 'AB' },
|
|
61
|
+
{ fallback: 'CD' },
|
|
62
|
+
]);
|
|
63
|
+
fixture.componentInstance.max.set(2);
|
|
64
|
+
fixture.detectChanges();
|
|
65
|
+
const fallbacks = el.querySelectorAll('.bg-muted');
|
|
66
|
+
expect(fallbacks.length).toBe(2);
|
|
67
|
+
expect(fallbacks[0].textContent).toContain('AB');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should have aria-label on group', () => {
|
|
71
|
+
const group = el.querySelector('[role="group"]');
|
|
72
|
+
expect(group?.getAttribute('aria-label')).toBe('Group of 5 users');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
computed,
|
|
5
|
+
input,
|
|
6
|
+
} from '@angular/core';
|
|
7
|
+
import { cn } from '../core/utils/cn';
|
|
8
|
+
|
|
9
|
+
export interface AvatarGroupItem {
|
|
10
|
+
src?: string;
|
|
11
|
+
alt?: string;
|
|
12
|
+
fallback?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sizeMap = {
|
|
16
|
+
sm: { avatar: 'h-7 w-7 text-xs', counter: 'h-7 w-7 text-[10px]' },
|
|
17
|
+
md: { avatar: 'h-9 w-9 text-sm', counter: 'h-9 w-9 text-xs' },
|
|
18
|
+
lg: { avatar: 'h-11 w-11 text-base', counter: 'h-11 w-11 text-sm' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const spacingMap = {
|
|
22
|
+
tight: '-space-x-3',
|
|
23
|
+
normal: '-space-x-2',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type AvatarGroupSize = 'sm' | 'md' | 'lg';
|
|
27
|
+
export type AvatarGroupSpacing = 'tight' | 'normal';
|
|
28
|
+
|
|
29
|
+
@Component({
|
|
30
|
+
selector: 'sny-avatar-group',
|
|
31
|
+
standalone: true,
|
|
32
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
33
|
+
template: `
|
|
34
|
+
<div [class]="containerClass()" role="group" [attr.aria-label]="'Group of ' + items().length + ' users'">
|
|
35
|
+
@for (item of visibleItems(); track $index) {
|
|
36
|
+
@if (item.src) {
|
|
37
|
+
<img
|
|
38
|
+
[src]="item.src"
|
|
39
|
+
[alt]="item.alt ?? ''"
|
|
40
|
+
[class]="avatarClass()"
|
|
41
|
+
/>
|
|
42
|
+
} @else {
|
|
43
|
+
<div [class]="fallbackClass()">
|
|
44
|
+
{{ item.fallback ?? '?' }}
|
|
45
|
+
</div>
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
@if (overflowCount() > 0) {
|
|
49
|
+
<div [class]="counterClass()" [title]="overflowCount() + ' more'">
|
|
50
|
+
+{{ overflowCount() }}
|
|
51
|
+
</div>
|
|
52
|
+
}
|
|
53
|
+
</div>
|
|
54
|
+
`,
|
|
55
|
+
})
|
|
56
|
+
export class SnyAvatarGroupComponent {
|
|
57
|
+
readonly items = input.required<AvatarGroupItem[]>();
|
|
58
|
+
readonly max = input(3);
|
|
59
|
+
readonly size = input<AvatarGroupSize>('md');
|
|
60
|
+
readonly spacing = input<AvatarGroupSpacing>('normal');
|
|
61
|
+
|
|
62
|
+
readonly visibleItems = computed(() => this.items().slice(0, this.max()));
|
|
63
|
+
readonly overflowCount = computed(() => Math.max(0, this.items().length - this.max()));
|
|
64
|
+
|
|
65
|
+
readonly containerClass = computed(() =>
|
|
66
|
+
cn('flex items-center', spacingMap[this.spacing()])
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
readonly avatarClass = computed(() =>
|
|
70
|
+
cn(
|
|
71
|
+
'inline-block rounded-full object-cover ring-2 ring-background',
|
|
72
|
+
sizeMap[this.size()].avatar
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
readonly fallbackClass = computed(() =>
|
|
77
|
+
cn(
|
|
78
|
+
'inline-flex items-center justify-center rounded-full bg-muted text-muted-foreground font-medium ring-2 ring-background',
|
|
79
|
+
sizeMap[this.size()].avatar
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
readonly counterClass = computed(() =>
|
|
84
|
+
cn(
|
|
85
|
+
'inline-flex items-center justify-center rounded-full bg-muted text-muted-foreground font-semibold ring-2 ring-background',
|
|
86
|
+
sizeMap[this.size()].counter
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SnyAvatarGroupComponent, type AvatarGroupItem, type AvatarGroupSize, type AvatarGroupSpacing } from './avatar-group.component';
|
|
@@ -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,153 @@
|
|
|
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
|
+
standalone: true,
|
|
19
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
20
|
+
providers: [
|
|
21
|
+
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyNumberInputComponent), multi: true },
|
|
22
|
+
],
|
|
23
|
+
template: `
|
|
24
|
+
<div [class]="containerClass()">
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
class="px-2.5 hover:bg-accent transition-colors border-r border-border disabled:opacity-40 disabled:cursor-not-allowed"
|
|
28
|
+
[disabled]="isDisabled() || atMin()"
|
|
29
|
+
(click)="decrement()"
|
|
30
|
+
aria-label="Decrease"
|
|
31
|
+
>
|
|
32
|
+
<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>
|
|
33
|
+
</button>
|
|
34
|
+
<input
|
|
35
|
+
#inputEl
|
|
36
|
+
type="text"
|
|
37
|
+
inputmode="decimal"
|
|
38
|
+
class="flex-1 w-14 text-center outline-none bg-transparent font-medium"
|
|
39
|
+
[value]="inputValue()"
|
|
40
|
+
[disabled]="isDisabled()"
|
|
41
|
+
[placeholder]="placeholder()"
|
|
42
|
+
[attr.aria-label]="'Number input'"
|
|
43
|
+
[attr.aria-valuemin]="min() ?? null"
|
|
44
|
+
[attr.aria-valuemax]="max() ?? null"
|
|
45
|
+
[attr.aria-valuenow]="value()"
|
|
46
|
+
role="spinbutton"
|
|
47
|
+
(input)="onInput($event)"
|
|
48
|
+
(blur)="commitValue()"
|
|
49
|
+
(keydown)="onKeydown($event)"
|
|
50
|
+
/>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
class="px-2.5 hover:bg-accent transition-colors border-l border-border disabled:opacity-40 disabled:cursor-not-allowed"
|
|
54
|
+
[disabled]="isDisabled() || atMax()"
|
|
55
|
+
(click)="increment()"
|
|
56
|
+
aria-label="Increase"
|
|
57
|
+
>
|
|
58
|
+
<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>
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
`,
|
|
62
|
+
})
|
|
63
|
+
export class SnyNumberInputComponent implements ControlValueAccessor {
|
|
64
|
+
readonly value = model(0);
|
|
65
|
+
readonly min = input<number | null>(null);
|
|
66
|
+
readonly max = input<number | null>(null);
|
|
67
|
+
readonly step = input(1);
|
|
68
|
+
readonly disabled = input(false);
|
|
69
|
+
readonly size = input<NumberInputSize>('md');
|
|
70
|
+
readonly placeholder = input('');
|
|
71
|
+
readonly class = input<string>('');
|
|
72
|
+
|
|
73
|
+
readonly inputValue = signal('0');
|
|
74
|
+
private readonly _disabledByCva = signal(false);
|
|
75
|
+
readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
|
|
76
|
+
|
|
77
|
+
readonly atMin = computed(() => this.min() !== null && this.value() <= this.min()!);
|
|
78
|
+
readonly atMax = computed(() => this.max() !== null && this.value() >= this.max()!);
|
|
79
|
+
|
|
80
|
+
readonly containerClass = computed(() =>
|
|
81
|
+
cn(numberInputVariants({ size: this.size() }), this.isDisabled() && 'opacity-50', this.class())
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
private _onChange: (value: number) => void = () => {};
|
|
85
|
+
private _onTouched: () => void = () => {};
|
|
86
|
+
|
|
87
|
+
constructor() {
|
|
88
|
+
effect(() => {
|
|
89
|
+
const val = this.value();
|
|
90
|
+
untracked(() => this.inputValue.set(String(val)));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
writeValue(val: number): void {
|
|
95
|
+
this.value.set(val ?? 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
registerOnChange(fn: (value: number) => void): void {
|
|
99
|
+
this._onChange = fn;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
registerOnTouched(fn: () => void): void {
|
|
103
|
+
this._onTouched = fn;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setDisabledState(isDisabled: boolean): void {
|
|
107
|
+
this._disabledByCva.set(isDisabled);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
increment(): void {
|
|
111
|
+
this.setValue(this.value() + this.step());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
decrement(): void {
|
|
115
|
+
this.setValue(this.value() - this.step());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onInput(event: Event): void {
|
|
119
|
+
this.inputValue.set((event.target as HTMLInputElement).value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
commitValue(): void {
|
|
123
|
+
const parsed = parseFloat(this.inputValue());
|
|
124
|
+
if (isNaN(parsed)) {
|
|
125
|
+
this.inputValue.set(String(this.value()));
|
|
126
|
+
} else {
|
|
127
|
+
this.setValue(parsed);
|
|
128
|
+
}
|
|
129
|
+
this._onTouched();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
onKeydown(event: KeyboardEvent): void {
|
|
133
|
+
switch (event.key) {
|
|
134
|
+
case 'ArrowUp':
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
this.increment();
|
|
137
|
+
break;
|
|
138
|
+
case 'ArrowDown':
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
this.decrement();
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private setValue(raw: number): void {
|
|
146
|
+
let clamped = raw;
|
|
147
|
+
if (this.min() !== null) clamped = Math.max(this.min()!, clamped);
|
|
148
|
+
if (this.max() !== null) clamped = Math.min(this.max()!, clamped);
|
|
149
|
+
const rounded = parseFloat(clamped.toFixed(10));
|
|
150
|
+
this.value.set(rounded);
|
|
151
|
+
this._onChange(rounded);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -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';
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import {
|
|
4
|
+
SnyPopoverDirective,
|
|
5
|
+
SnyPopoverTriggerDirective,
|
|
6
|
+
SnyPopoverContentDirective,
|
|
7
|
+
} from './popover.directives';
|
|
8
|
+
|
|
9
|
+
@Component({
|
|
10
|
+
standalone: true,
|
|
11
|
+
imports: [SnyPopoverDirective, SnyPopoverTriggerDirective, SnyPopoverContentDirective],
|
|
12
|
+
template: `
|
|
13
|
+
<div snyPopover [matchWidth]="matchWidth()" [closeOnOutside]="closeOnOutside()" [closeOnEscape]="closeOnEscape()" #pop="snyPopover">
|
|
14
|
+
<button snyPopoverTrigger>Open</button>
|
|
15
|
+
<div snyPopoverContent class="p-4">
|
|
16
|
+
<p>Popover content</p>
|
|
17
|
+
<button class="close-btn" (click)="pop.close()">Close</button>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
`,
|
|
21
|
+
})
|
|
22
|
+
class TestHost {
|
|
23
|
+
matchWidth = signal(false);
|
|
24
|
+
closeOnOutside = signal(true);
|
|
25
|
+
closeOnEscape = signal(true);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('SnyPopoverDirective', () => {
|
|
29
|
+
let fixture: ComponentFixture<TestHost>;
|
|
30
|
+
let el: HTMLElement;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
|
|
34
|
+
fixture = TestBed.createComponent(TestHost);
|
|
35
|
+
fixture.detectChanges();
|
|
36
|
+
el = fixture.nativeElement;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function getTrigger(): HTMLButtonElement {
|
|
40
|
+
return el.querySelector('[snypopovertrigger]') as HTMLButtonElement;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getContent(): HTMLElement | null {
|
|
44
|
+
return el.querySelector('[snyPopoverContent], [snypopovercontent]');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isVisible(): boolean {
|
|
48
|
+
const content = getContent();
|
|
49
|
+
return content ? content.style.display !== 'none' : false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
it('should render trigger and hidden content', () => {
|
|
53
|
+
expect(getTrigger()).not.toBeNull();
|
|
54
|
+
expect(getContent()).not.toBeNull();
|
|
55
|
+
expect(isVisible()).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should open on trigger click', () => {
|
|
59
|
+
getTrigger().click();
|
|
60
|
+
fixture.detectChanges();
|
|
61
|
+
expect(isVisible()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should close on second trigger click', () => {
|
|
65
|
+
getTrigger().click();
|
|
66
|
+
fixture.detectChanges();
|
|
67
|
+
expect(isVisible()).toBe(true);
|
|
68
|
+
|
|
69
|
+
getTrigger().click();
|
|
70
|
+
fixture.detectChanges();
|
|
71
|
+
expect(isVisible()).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should set aria-expanded on trigger', () => {
|
|
75
|
+
expect(getTrigger().getAttribute('aria-expanded')).toBe('false');
|
|
76
|
+
getTrigger().click();
|
|
77
|
+
fixture.detectChanges();
|
|
78
|
+
expect(getTrigger().getAttribute('aria-expanded')).toBe('true');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should have aria-haspopup on trigger', () => {
|
|
82
|
+
expect(getTrigger().getAttribute('aria-haspopup')).toBe('dialog');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should have role=dialog on content', () => {
|
|
86
|
+
expect(getContent()?.getAttribute('role')).toBe('dialog');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should close on click outside', () => {
|
|
90
|
+
getTrigger().click();
|
|
91
|
+
fixture.detectChanges();
|
|
92
|
+
expect(isVisible()).toBe(true);
|
|
93
|
+
|
|
94
|
+
document.body.click();
|
|
95
|
+
fixture.detectChanges();
|
|
96
|
+
expect(isVisible()).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should not close on click outside when closeOnOutside=false', () => {
|
|
100
|
+
fixture.componentInstance.closeOnOutside.set(false);
|
|
101
|
+
fixture.detectChanges();
|
|
102
|
+
|
|
103
|
+
getTrigger().click();
|
|
104
|
+
fixture.detectChanges();
|
|
105
|
+
expect(isVisible()).toBe(true);
|
|
106
|
+
|
|
107
|
+
document.body.click();
|
|
108
|
+
fixture.detectChanges();
|
|
109
|
+
expect(isVisible()).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should close on escape', () => {
|
|
113
|
+
getTrigger().click();
|
|
114
|
+
fixture.detectChanges();
|
|
115
|
+
expect(isVisible()).toBe(true);
|
|
116
|
+
|
|
117
|
+
const host = el.querySelector('[snyPopover], [snypopover]') as HTMLElement;
|
|
118
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
119
|
+
fixture.detectChanges();
|
|
120
|
+
expect(isVisible()).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should not close on escape when closeOnEscape=false', () => {
|
|
124
|
+
fixture.componentInstance.closeOnEscape.set(false);
|
|
125
|
+
fixture.detectChanges();
|
|
126
|
+
|
|
127
|
+
getTrigger().click();
|
|
128
|
+
fixture.detectChanges();
|
|
129
|
+
expect(isVisible()).toBe(true);
|
|
130
|
+
|
|
131
|
+
const host = el.querySelector('[snyPopover], [snypopover]') as HTMLElement;
|
|
132
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
133
|
+
fixture.detectChanges();
|
|
134
|
+
expect(isVisible()).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should close programmatically via template ref', () => {
|
|
138
|
+
getTrigger().click();
|
|
139
|
+
fixture.detectChanges();
|
|
140
|
+
expect(isVisible()).toBe(true);
|
|
141
|
+
|
|
142
|
+
const closeBtn = el.querySelector('.close-btn') as HTMLButtonElement;
|
|
143
|
+
closeBtn.click();
|
|
144
|
+
fixture.detectChanges();
|
|
145
|
+
expect(isVisible()).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|