@sonny-ui/core 0.1.0-alpha.15 → 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 +1987 -4
- 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/color-picker/color-picker.component.spec.ts +328 -0
- package/src/lib/color-picker/color-picker.component.ts +537 -0
- package/src/lib/color-picker/color-picker.types.ts +24 -0
- package/src/lib/color-picker/color-picker.utils.ts +183 -0
- package/src/lib/color-picker/color-picker.variants.ts +17 -0
- package/src/lib/color-picker/index.ts +20 -0
- package/src/lib/command-palette/command-palette.component.spec.ts +178 -0
- package/src/lib/command-palette/command-palette.component.ts +195 -0
- package/src/lib/command-palette/command-palette.service.ts +36 -0
- package/src/lib/command-palette/command-palette.types.ts +23 -0
- package/src/lib/command-palette/index.ts +7 -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/otp-input/index.ts +2 -0
- package/src/lib/otp-input/otp-input.component.spec.ts +252 -0
- package/src/lib/otp-input/otp-input.component.ts +275 -0
- package/src/lib/otp-input/otp-input.variants.ts +18 -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 +351 -3
|
@@ -0,0 +1,190 @@
|
|
|
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 { SnyTagInputComponent } from './tag-input.component';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [SnyTagInputComponent],
|
|
9
|
+
template: `
|
|
10
|
+
<sny-tag-input
|
|
11
|
+
[(value)]="tags"
|
|
12
|
+
[maxTags]="maxTags()"
|
|
13
|
+
[allowDuplicates]="allowDuplicates()"
|
|
14
|
+
[disabled]="disabled()"
|
|
15
|
+
[validate]="validateFn()"
|
|
16
|
+
(tagAdded)="lastAdded = $event"
|
|
17
|
+
(tagRemoved)="lastRemoved = $event"
|
|
18
|
+
/>
|
|
19
|
+
`,
|
|
20
|
+
})
|
|
21
|
+
class TestHost {
|
|
22
|
+
tags = signal<string[]>([]);
|
|
23
|
+
maxTags = signal<number | null>(null);
|
|
24
|
+
allowDuplicates = signal(false);
|
|
25
|
+
disabled = signal(false);
|
|
26
|
+
validateFn = signal<((t: string) => boolean) | null>(null);
|
|
27
|
+
lastAdded: string | null = null;
|
|
28
|
+
lastRemoved: string | null = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('SnyTagInputComponent', () => {
|
|
32
|
+
let fixture: ComponentFixture<TestHost>;
|
|
33
|
+
let el: HTMLElement;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
|
|
37
|
+
fixture = TestBed.createComponent(TestHost);
|
|
38
|
+
fixture.detectChanges();
|
|
39
|
+
el = fixture.nativeElement;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function getInput(): HTMLInputElement {
|
|
43
|
+
return el.querySelector('input') as HTMLInputElement;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getTags(): string[] {
|
|
47
|
+
return Array.from(el.querySelectorAll('span')).map((s) => s.textContent?.trim().replace('×', '').trim() ?? '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function typeAndEnter(text: string): void {
|
|
51
|
+
const input = getInput();
|
|
52
|
+
input.value = text;
|
|
53
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
54
|
+
fixture.detectChanges();
|
|
55
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
56
|
+
fixture.detectChanges();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it('should render empty with placeholder', () => {
|
|
60
|
+
expect(getInput().placeholder).toBe('Add tag...');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should add tag on Enter', () => {
|
|
64
|
+
typeAndEnter('Angular');
|
|
65
|
+
expect(fixture.componentInstance.tags()).toContain('Angular');
|
|
66
|
+
expect(fixture.componentInstance.lastAdded).toBe('Angular');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should add tag on comma', () => {
|
|
70
|
+
const input = getInput();
|
|
71
|
+
input.value = 'React,';
|
|
72
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
73
|
+
fixture.detectChanges();
|
|
74
|
+
expect(fixture.componentInstance.tags()).toContain('React');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should reject duplicates when allowDuplicates=false', () => {
|
|
78
|
+
typeAndEnter('Angular');
|
|
79
|
+
typeAndEnter('Angular');
|
|
80
|
+
expect(fixture.componentInstance.tags().filter((t) => t === 'Angular').length).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should allow duplicates when allowDuplicates=true', () => {
|
|
84
|
+
fixture.componentInstance.allowDuplicates.set(true);
|
|
85
|
+
fixture.detectChanges();
|
|
86
|
+
typeAndEnter('Angular');
|
|
87
|
+
typeAndEnter('Angular');
|
|
88
|
+
expect(fixture.componentInstance.tags().filter((t) => t === 'Angular').length).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should respect maxTags', () => {
|
|
92
|
+
fixture.componentInstance.maxTags.set(2);
|
|
93
|
+
fixture.detectChanges();
|
|
94
|
+
typeAndEnter('A');
|
|
95
|
+
typeAndEnter('B');
|
|
96
|
+
// Input should be hidden now (atMax), so we can't type more
|
|
97
|
+
const input = el.querySelector('input');
|
|
98
|
+
expect(input).toBeNull(); // hidden when at max
|
|
99
|
+
expect(fixture.componentInstance.tags().length).toBe(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should validate with custom function', () => {
|
|
103
|
+
fixture.componentInstance.validateFn.set((t: string) => t.length >= 3);
|
|
104
|
+
fixture.detectChanges();
|
|
105
|
+
typeAndEnter('AB');
|
|
106
|
+
expect(fixture.componentInstance.tags().length).toBe(0);
|
|
107
|
+
typeAndEnter('ABC');
|
|
108
|
+
expect(fixture.componentInstance.tags()).toContain('ABC');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should remove tag on X click', () => {
|
|
112
|
+
typeAndEnter('Angular');
|
|
113
|
+
fixture.detectChanges();
|
|
114
|
+
const removeBtn = el.querySelector('[aria-label="Remove Angular"]') as HTMLButtonElement;
|
|
115
|
+
removeBtn.click();
|
|
116
|
+
fixture.detectChanges();
|
|
117
|
+
expect(fixture.componentInstance.tags().length).toBe(0);
|
|
118
|
+
expect(fixture.componentInstance.lastRemoved).toBe('Angular');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should remove last tag on Backspace with empty input', () => {
|
|
122
|
+
typeAndEnter('A');
|
|
123
|
+
typeAndEnter('B');
|
|
124
|
+
const input = getInput();
|
|
125
|
+
input.value = '';
|
|
126
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
127
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
|
|
128
|
+
fixture.detectChanges();
|
|
129
|
+
expect(fixture.componentInstance.tags()).toEqual(['A']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should add tag on blur when addOnBlur=true', () => {
|
|
133
|
+
const input = getInput();
|
|
134
|
+
input.value = 'BlurTag';
|
|
135
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
136
|
+
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
137
|
+
fixture.detectChanges();
|
|
138
|
+
expect(fixture.componentInstance.tags()).toContain('BlurTag');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should disable when disabled', () => {
|
|
142
|
+
fixture.componentInstance.disabled.set(true);
|
|
143
|
+
fixture.detectChanges();
|
|
144
|
+
expect(getInput().disabled).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should emit tagAdded and tagRemoved', () => {
|
|
148
|
+
typeAndEnter('Test');
|
|
149
|
+
expect(fixture.componentInstance.lastAdded).toBe('Test');
|
|
150
|
+
const removeBtn = el.querySelector('[aria-label="Remove Test"]') as HTMLButtonElement;
|
|
151
|
+
removeBtn.click();
|
|
152
|
+
fixture.detectChanges();
|
|
153
|
+
expect(fixture.componentInstance.lastRemoved).toBe('Test');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
@Component({
|
|
158
|
+
standalone: true,
|
|
159
|
+
imports: [ReactiveFormsModule, SnyTagInputComponent],
|
|
160
|
+
template: `<sny-tag-input [formControl]="ctrl" />`,
|
|
161
|
+
})
|
|
162
|
+
class ReactiveHost {
|
|
163
|
+
ctrl = new FormControl<string[]>([]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
describe('SnyTagInputComponent — Reactive Forms', () => {
|
|
167
|
+
let fixture: ComponentFixture<ReactiveHost>;
|
|
168
|
+
|
|
169
|
+
beforeEach(async () => {
|
|
170
|
+
await TestBed.configureTestingModule({ imports: [ReactiveHost] }).compileComponents();
|
|
171
|
+
fixture = TestBed.createComponent(ReactiveHost);
|
|
172
|
+
fixture.detectChanges();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should populate tags from FormControl', () => {
|
|
176
|
+
fixture.componentInstance.ctrl.setValue(['A', 'B', 'C']);
|
|
177
|
+
fixture.detectChanges();
|
|
178
|
+
const tags = fixture.nativeElement.querySelectorAll('span');
|
|
179
|
+
expect(tags.length).toBe(3);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should update FormControl when tag added', () => {
|
|
183
|
+
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
|
184
|
+
input.value = 'New';
|
|
185
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
186
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
187
|
+
fixture.detectChanges();
|
|
188
|
+
expect(fixture.componentInstance.ctrl.value).toContain('New');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
computed,
|
|
5
|
+
ElementRef,
|
|
6
|
+
forwardRef,
|
|
7
|
+
input,
|
|
8
|
+
model,
|
|
9
|
+
output,
|
|
10
|
+
signal,
|
|
11
|
+
viewChild,
|
|
12
|
+
} from '@angular/core';
|
|
13
|
+
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
14
|
+
import { cn } from '../core/utils/cn';
|
|
15
|
+
import { tagInputContainerVariants, tagVariants, type TagInputSize } from './tag-input.variants';
|
|
16
|
+
|
|
17
|
+
@Component({
|
|
18
|
+
selector: 'sny-tag-input',
|
|
19
|
+
standalone: true,
|
|
20
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
21
|
+
providers: [
|
|
22
|
+
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyTagInputComponent), multi: true },
|
|
23
|
+
],
|
|
24
|
+
template: `
|
|
25
|
+
<div [class]="containerClass()" (click)="focusInput()">
|
|
26
|
+
@for (tag of value(); track tag; let i = $index) {
|
|
27
|
+
<span [class]="tagClass()">
|
|
28
|
+
{{ tag }}
|
|
29
|
+
@if (removable() && !isDisabled()) {
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
class="hover:text-destructive transition-colors leading-none"
|
|
33
|
+
(click)="removeTag(i); $event.stopPropagation()"
|
|
34
|
+
[attr.aria-label]="'Remove ' + tag"
|
|
35
|
+
>
|
|
36
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
37
|
+
</button>
|
|
38
|
+
}
|
|
39
|
+
</span>
|
|
40
|
+
}
|
|
41
|
+
@if (!atMax()) {
|
|
42
|
+
<input
|
|
43
|
+
#inputEl
|
|
44
|
+
type="text"
|
|
45
|
+
class="flex-1 min-w-[80px] outline-none bg-transparent"
|
|
46
|
+
[placeholder]="value().length === 0 ? placeholder() : ''"
|
|
47
|
+
[disabled]="isDisabled()"
|
|
48
|
+
[value]="inputValue()"
|
|
49
|
+
(input)="onInput($event)"
|
|
50
|
+
(keydown)="onKeydown($event)"
|
|
51
|
+
(blur)="onBlur()"
|
|
52
|
+
[attr.aria-label]="'Add tag'"
|
|
53
|
+
/>
|
|
54
|
+
}
|
|
55
|
+
</div>
|
|
56
|
+
`,
|
|
57
|
+
})
|
|
58
|
+
export class SnyTagInputComponent implements ControlValueAccessor {
|
|
59
|
+
readonly value = model<string[]>([]);
|
|
60
|
+
readonly placeholder = input('Add tag...');
|
|
61
|
+
readonly maxTags = input<number | null>(null);
|
|
62
|
+
readonly allowDuplicates = input(false);
|
|
63
|
+
readonly removable = input(true);
|
|
64
|
+
readonly addOnBlur = input(true);
|
|
65
|
+
readonly separators = input<string[]>(['Enter', ',']);
|
|
66
|
+
readonly validate = input<((tag: string) => boolean) | null>(null);
|
|
67
|
+
readonly disabled = input(false);
|
|
68
|
+
readonly size = input<TagInputSize>('md');
|
|
69
|
+
readonly class = input<string>('');
|
|
70
|
+
|
|
71
|
+
readonly tagAdded = output<string>();
|
|
72
|
+
readonly tagRemoved = output<string>();
|
|
73
|
+
|
|
74
|
+
readonly inputValue = signal('');
|
|
75
|
+
private readonly _disabledByCva = signal(false);
|
|
76
|
+
readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
|
|
77
|
+
readonly atMax = computed(() => this.maxTags() !== null && this.value().length >= this.maxTags()!);
|
|
78
|
+
|
|
79
|
+
readonly containerClass = computed(() =>
|
|
80
|
+
cn(tagInputContainerVariants({ size: this.size() }), this.isDisabled() && 'opacity-50 cursor-not-allowed', this.class())
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
readonly tagClass = computed(() =>
|
|
84
|
+
cn(tagVariants({ size: this.size() }), 'bg-secondary text-secondary-foreground')
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('inputEl');
|
|
88
|
+
|
|
89
|
+
private _onChange: (value: string[]) => void = () => {};
|
|
90
|
+
private _onTouched: () => void = () => {};
|
|
91
|
+
|
|
92
|
+
writeValue(val: string[]): void {
|
|
93
|
+
this.value.set(val ?? []);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
registerOnChange(fn: (value: string[]) => void): void {
|
|
97
|
+
this._onChange = fn;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
registerOnTouched(fn: () => void): void {
|
|
101
|
+
this._onTouched = fn;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setDisabledState(isDisabled: boolean): void {
|
|
105
|
+
this._disabledByCva.set(isDisabled);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
focusInput(): void {
|
|
109
|
+
this.inputRef()?.nativeElement.focus();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onInput(event: Event): void {
|
|
113
|
+
const val = (event.target as HTMLInputElement).value;
|
|
114
|
+
// Check if separator character was typed (e.g. comma)
|
|
115
|
+
const seps = this.separators().filter((s) => s.length === 1);
|
|
116
|
+
for (const sep of seps) {
|
|
117
|
+
if (val.includes(sep)) {
|
|
118
|
+
const parts = val.split(sep);
|
|
119
|
+
for (const part of parts) {
|
|
120
|
+
this.addTag(part);
|
|
121
|
+
}
|
|
122
|
+
this.inputValue.set('');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
this.inputValue.set(val);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
onKeydown(event: KeyboardEvent): void {
|
|
130
|
+
if (this.separators().includes(event.key) && event.key !== ',') {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
this.addTag(this.inputValue());
|
|
133
|
+
this.inputValue.set('');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (event.key === 'Backspace' && this.inputValue() === '') {
|
|
138
|
+
const tags = this.value();
|
|
139
|
+
if (tags.length > 0) {
|
|
140
|
+
this.removeTag(tags.length - 1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
onBlur(): void {
|
|
146
|
+
if (this.addOnBlur() && this.inputValue().trim()) {
|
|
147
|
+
this.addTag(this.inputValue());
|
|
148
|
+
this.inputValue.set('');
|
|
149
|
+
}
|
|
150
|
+
this._onTouched();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
addTag(raw: string): void {
|
|
154
|
+
const tag = raw.trim();
|
|
155
|
+
if (!tag) return;
|
|
156
|
+
if (this.atMax()) return;
|
|
157
|
+
if (!this.allowDuplicates() && this.value().includes(tag)) return;
|
|
158
|
+
|
|
159
|
+
const validateFn = this.validate();
|
|
160
|
+
if (validateFn && !validateFn(tag)) return;
|
|
161
|
+
|
|
162
|
+
this.value.update((tags) => [...tags, tag]);
|
|
163
|
+
this._onChange(this.value());
|
|
164
|
+
this.tagAdded.emit(tag);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
removeTag(index: number): void {
|
|
168
|
+
const removed = this.value()[index];
|
|
169
|
+
this.value.update((tags) => tags.filter((_, i) => i !== index));
|
|
170
|
+
this._onChange(this.value());
|
|
171
|
+
this.tagRemoved.emit(removed);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const tagInputContainerVariants = cva(
|
|
4
|
+
'flex flex-wrap gap-1.5 border border-border rounded-md bg-background px-2 cursor-text transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: 'min-h-[36px] py-1 text-xs',
|
|
9
|
+
md: 'min-h-[40px] py-1.5 text-sm',
|
|
10
|
+
lg: 'min-h-[44px] py-2 text-base',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: { size: 'md' },
|
|
14
|
+
}
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const tagVariants = cva(
|
|
18
|
+
'inline-flex items-center gap-1 rounded-md font-medium',
|
|
19
|
+
{
|
|
20
|
+
variants: {
|
|
21
|
+
size: {
|
|
22
|
+
sm: 'px-1.5 py-0.5 text-xs',
|
|
23
|
+
md: 'px-2 py-0.5 text-sm',
|
|
24
|
+
lg: 'px-2.5 py-1 text-sm',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
defaultVariants: { size: 'md' },
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export type TagInputSize = 'sm' | 'md' | 'lg';
|