@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.
Files changed (34) hide show
  1. package/README.md +109 -55
  2. package/fesm2022/sonny-ui-core.mjs +1987 -4
  3. package/fesm2022/sonny-ui-core.mjs.map +1 -1
  4. package/package.json +1 -1
  5. package/src/lib/avatar-group/avatar-group.component.spec.ts +74 -0
  6. package/src/lib/avatar-group/avatar-group.component.ts +89 -0
  7. package/src/lib/avatar-group/index.ts +1 -0
  8. package/src/lib/color-picker/color-picker.component.spec.ts +328 -0
  9. package/src/lib/color-picker/color-picker.component.ts +537 -0
  10. package/src/lib/color-picker/color-picker.types.ts +24 -0
  11. package/src/lib/color-picker/color-picker.utils.ts +183 -0
  12. package/src/lib/color-picker/color-picker.variants.ts +17 -0
  13. package/src/lib/color-picker/index.ts +20 -0
  14. package/src/lib/command-palette/command-palette.component.spec.ts +178 -0
  15. package/src/lib/command-palette/command-palette.component.ts +195 -0
  16. package/src/lib/command-palette/command-palette.service.ts +36 -0
  17. package/src/lib/command-palette/command-palette.types.ts +23 -0
  18. package/src/lib/command-palette/index.ts +7 -0
  19. package/src/lib/number-input/index.ts +2 -0
  20. package/src/lib/number-input/number-input.component.spec.ts +151 -0
  21. package/src/lib/number-input/number-input.component.ts +153 -0
  22. package/src/lib/number-input/number-input.variants.ts +17 -0
  23. package/src/lib/otp-input/index.ts +2 -0
  24. package/src/lib/otp-input/otp-input.component.spec.ts +252 -0
  25. package/src/lib/otp-input/otp-input.component.ts +275 -0
  26. package/src/lib/otp-input/otp-input.variants.ts +18 -0
  27. package/src/lib/popover/index.ts +6 -0
  28. package/src/lib/popover/popover.directives.spec.ts +147 -0
  29. package/src/lib/popover/popover.directives.ts +155 -0
  30. package/src/lib/tag-input/index.ts +2 -0
  31. package/src/lib/tag-input/tag-input.component.spec.ts +190 -0
  32. package/src/lib/tag-input/tag-input.component.ts +173 -0
  33. package/src/lib/tag-input/tag-input.variants.ts +31 -0
  34. package/types/sonny-ui-core.d.ts +351 -3
@@ -0,0 +1,275 @@
1
+ import {
2
+ afterNextRender,
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ computed,
6
+ effect,
7
+ ElementRef,
8
+ forwardRef,
9
+ input,
10
+ linkedSignal,
11
+ model,
12
+ output,
13
+ signal,
14
+ untracked,
15
+ viewChildren,
16
+ } from '@angular/core';
17
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
18
+ import { cn } from '../core/utils/cn';
19
+ import { otpCellVariants, type OtpInputSize, type OtpInputType } from './otp-input.variants';
20
+
21
+ @Component({
22
+ selector: 'sny-otp-input',
23
+ standalone: true,
24
+ changeDetection: ChangeDetectionStrategy.OnPush,
25
+ providers: [
26
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyOtpInputComponent), multi: true },
27
+ ],
28
+ template: `
29
+ <div
30
+ role="group"
31
+ [attr.aria-label]="'OTP input, ' + length() + ' digits'"
32
+ class="flex items-center gap-2"
33
+ >
34
+ @for (digit of digits(); track $index; let i = $index) {
35
+ @if (separator() !== null && i === separator() && i > 0) {
36
+ <span class="text-muted-foreground text-lg select-none" aria-hidden="true">—</span>
37
+ }
38
+ <input
39
+ #inputEl
40
+ [type]="mask() ? 'password' : 'text'"
41
+ [inputMode]="type() === 'number' ? 'numeric' : 'text'"
42
+ [attr.pattern]="type() === 'number' ? '[0-9]' : '[a-zA-Z0-9]'"
43
+ maxlength="1"
44
+ autocomplete="one-time-code"
45
+ [value]="digit"
46
+ [placeholder]="placeholder()"
47
+ [disabled]="isDisabled()"
48
+ [class]="cellClass(i)"
49
+ [attr.aria-label]="'Digit ' + (i + 1) + ' of ' + length()"
50
+ (input)="onInput($event, i)"
51
+ (keydown)="onKeydown($event, i)"
52
+ (paste)="onPaste($event, i)"
53
+ (focus)="focusedIndex.set(i)"
54
+ (blur)="onBlur()"
55
+ />
56
+ }
57
+ </div>
58
+ `,
59
+ })
60
+ export class SnyOtpInputComponent implements ControlValueAccessor {
61
+ // Public API
62
+ readonly length = input(6);
63
+ readonly type = input<OtpInputType>('number');
64
+ readonly size = input<OtpInputSize>('md');
65
+ readonly disabled = input(false);
66
+ readonly mask = input(false);
67
+ readonly autoFocus = input(true);
68
+ readonly placeholder = input('');
69
+ readonly separator = input<number | null>(null);
70
+ readonly status = input<'idle' | 'loading' | 'success' | 'error'>('idle');
71
+ readonly value = model('');
72
+
73
+ readonly completed = output<string>();
74
+
75
+ // Internal state
76
+ readonly digits = linkedSignal<string[]>(() => Array(this.length()).fill(''));
77
+ readonly focusedIndex = signal(-1);
78
+ readonly inputRefs = viewChildren<ElementRef<HTMLInputElement>>('inputEl');
79
+
80
+ private readonly _disabledByCva = signal(false);
81
+ readonly isDisabled = computed(() => this.disabled() || this._disabledByCva() || this.status() === 'loading');
82
+
83
+ private _onChange: (value: string) => void = () => {};
84
+ private _onTouched: () => void = () => {};
85
+
86
+ // Computed
87
+ readonly fullValue = computed(() => this.digits().join(''));
88
+ readonly isComplete = computed(() => {
89
+ const d = this.digits();
90
+ return d.length === this.length() && d.every((c) => c !== '');
91
+ });
92
+
93
+ constructor() {
94
+ // Sync value → digits when value changes externally (e.g. reset)
95
+ effect(() => {
96
+ const val = this.value();
97
+ untracked(() => {
98
+ const chars = val.split('').slice(0, this.length());
99
+ const padded = [...chars, ...Array(this.length() - chars.length).fill('')];
100
+ const current = this.digits();
101
+ if (padded.join('') !== current.join('')) {
102
+ this.digits.set(padded);
103
+ }
104
+ });
105
+ });
106
+
107
+ afterNextRender(() => {
108
+ if (this.autoFocus()) {
109
+ this.focusInput(0);
110
+ }
111
+ });
112
+ }
113
+
114
+ // CVA
115
+ writeValue(val: string): void {
116
+ const str = val ?? '';
117
+ this.value.set(str);
118
+ const chars = str.split('').slice(0, this.length());
119
+ const padded = [...chars, ...Array(this.length() - chars.length).fill('')];
120
+ this.digits.set(padded);
121
+ }
122
+
123
+ registerOnChange(fn: (value: string) => void): void {
124
+ this._onChange = fn;
125
+ }
126
+
127
+ registerOnTouched(fn: () => void): void {
128
+ this._onTouched = fn;
129
+ }
130
+
131
+ setDisabledState(isDisabled: boolean): void {
132
+ this._disabledByCva.set(isDisabled);
133
+ }
134
+
135
+ // Cell class
136
+ cellClass(index: number): string {
137
+ const isFocused = this.focusedIndex() === index;
138
+ const hasValue = this.digits()[index] !== '';
139
+ const st = this.status();
140
+ return cn(
141
+ otpCellVariants({ size: this.size() }),
142
+ st === 'idle' && isFocused && 'border-primary ring-2 ring-ring',
143
+ st === 'idle' && hasValue && !isFocused && 'border-primary/50',
144
+ st === 'loading' && 'border-muted-foreground/30 opacity-70',
145
+ st === 'success' && 'border-green-500 bg-green-500/5',
146
+ st === 'error' && 'border-destructive bg-destructive/5 animate-shake',
147
+ );
148
+ }
149
+
150
+ // Input handler
151
+ onInput(event: Event, index: number): void {
152
+ const input = event.target as HTMLInputElement;
153
+ const char = input.value.slice(-1);
154
+
155
+ if (!this.isValidChar(char)) {
156
+ input.value = this.digits()[index];
157
+ return;
158
+ }
159
+
160
+ this.setDigit(index, char);
161
+
162
+ if (index < this.length() - 1) {
163
+ this.focusInput(index + 1);
164
+ }
165
+
166
+ this.emitValue();
167
+ }
168
+
169
+ // Keyboard handler
170
+ onKeydown(event: KeyboardEvent, index: number): void {
171
+ switch (event.key) {
172
+ case 'Backspace':
173
+ event.preventDefault();
174
+ if (this.digits()[index] !== '') {
175
+ this.setDigit(index, '');
176
+ this.emitValue();
177
+ } else if (index > 0) {
178
+ this.setDigit(index - 1, '');
179
+ this.focusInput(index - 1);
180
+ this.emitValue();
181
+ }
182
+ break;
183
+
184
+ case 'Delete':
185
+ event.preventDefault();
186
+ this.setDigit(index, '');
187
+ this.emitValue();
188
+ break;
189
+
190
+ case 'ArrowLeft':
191
+ event.preventDefault();
192
+ if (index > 0) this.focusInput(index - 1);
193
+ break;
194
+
195
+ case 'ArrowRight':
196
+ event.preventDefault();
197
+ if (index < this.length() - 1) this.focusInput(index + 1);
198
+ break;
199
+
200
+ case 'Home':
201
+ event.preventDefault();
202
+ this.focusInput(0);
203
+ break;
204
+
205
+ case 'End':
206
+ event.preventDefault();
207
+ this.focusInput(this.length() - 1);
208
+ break;
209
+ }
210
+ }
211
+
212
+ // Paste handler
213
+ onPaste(event: ClipboardEvent, index: number): void {
214
+ event.preventDefault();
215
+ const text = event.clipboardData?.getData('text') ?? '';
216
+ const chars = text.split('').filter((c) => this.isValidChar(c));
217
+
218
+ if (chars.length === 0) return;
219
+
220
+ const newDigits = [...this.digits()];
221
+ let lastFilledIndex = index;
222
+
223
+ for (let i = 0; i < chars.length && index + i < this.length(); i++) {
224
+ newDigits[index + i] = chars[i];
225
+ lastFilledIndex = index + i;
226
+ }
227
+
228
+ this.digits.set(newDigits);
229
+
230
+ const nextIndex = Math.min(lastFilledIndex + 1, this.length() - 1);
231
+ this.focusInput(nextIndex);
232
+ this.emitValue();
233
+ }
234
+
235
+ // Blur
236
+ onBlur(): void {
237
+ this.focusedIndex.set(-1);
238
+ this._onTouched();
239
+ }
240
+
241
+ // Helpers
242
+ private setDigit(index: number, char: string): void {
243
+ this.digits.update((d) => {
244
+ const next = [...d];
245
+ next[index] = char;
246
+ return next;
247
+ });
248
+ }
249
+
250
+ private emitValue(): void {
251
+ const val = this.fullValue();
252
+ this.value.set(val);
253
+ this._onChange(val);
254
+
255
+ if (this.isComplete()) {
256
+ this.completed.emit(val);
257
+ }
258
+ }
259
+
260
+ private focusInput(index: number): void {
261
+ const refs = this.inputRefs();
262
+ if (refs[index]) {
263
+ const el = refs[index].nativeElement;
264
+ el.focus();
265
+ el.select();
266
+ }
267
+ }
268
+
269
+ private isValidChar(char: string): boolean {
270
+ if (!char || char.length !== 1) return false;
271
+ if (this.type() === 'number') return /^[0-9]$/.test(char);
272
+ return /^[a-zA-Z0-9]$/.test(char);
273
+ }
274
+
275
+ }
@@ -0,0 +1,18 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const otpCellVariants = cva(
4
+ 'text-center font-mono font-semibold border border-border bg-background rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: 'h-9 w-9 text-sm',
9
+ md: 'h-11 w-11 text-lg',
10
+ lg: 'h-14 w-14 text-2xl',
11
+ },
12
+ },
13
+ defaultVariants: { size: 'md' },
14
+ }
15
+ );
16
+
17
+ export type OtpInputSize = 'sm' | 'md' | 'lg';
18
+ export type OtpInputType = 'number' | 'alphanumeric';
@@ -0,0 +1,6 @@
1
+ export {
2
+ SnyPopoverDirective,
3
+ SnyPopoverTriggerDirective,
4
+ SnyPopoverContentDirective,
5
+ SNY_POPOVER,
6
+ } from './popover.directives';
@@ -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
+ });
@@ -0,0 +1,155 @@
1
+ import {
2
+ Directive,
3
+ ElementRef,
4
+ HostListener,
5
+ InjectionToken,
6
+ OnDestroy,
7
+ computed,
8
+ inject,
9
+ input,
10
+ signal,
11
+ } from '@angular/core';
12
+ import { cn } from '../core/utils/cn';
13
+
14
+ export const SNY_POPOVER = new InjectionToken<SnyPopoverDirective>('SnyPopover');
15
+
16
+ @Directive({
17
+ selector: '[snyPopover]',
18
+ standalone: true,
19
+ exportAs: 'snyPopover',
20
+ providers: [{ provide: SNY_POPOVER, useExisting: SnyPopoverDirective }],
21
+ host: {
22
+ '[class]': '"relative inline-block"',
23
+ },
24
+ })
25
+ export class SnyPopoverDirective implements OnDestroy {
26
+ private readonly elRef = inject(ElementRef);
27
+
28
+ readonly matchWidth = input(false);
29
+ readonly offset = input(4);
30
+ readonly closeOnOutside = input(true);
31
+ readonly closeOnEscape = input(true);
32
+
33
+ readonly isOpen = signal(false);
34
+ readonly triggerEl = signal<HTMLElement | null>(null);
35
+ readonly panelEl = signal<HTMLElement | null>(null);
36
+
37
+ private scrollHandler: (() => void) | null = null;
38
+ private resizeHandler: (() => void) | null = null;
39
+
40
+ toggle(): void {
41
+ if (this.isOpen()) {
42
+ this.close();
43
+ } else {
44
+ this.open();
45
+ }
46
+ }
47
+
48
+ open(): void {
49
+ this.isOpen.set(true);
50
+ this.addListeners();
51
+ setTimeout(() => this.updatePosition());
52
+ }
53
+
54
+ close(): void {
55
+ this.isOpen.set(false);
56
+ this.removeListeners();
57
+ }
58
+
59
+ updatePosition(): void {
60
+ const trigger = this.triggerEl();
61
+ const panel = this.panelEl();
62
+ if (!trigger || !panel) return;
63
+ const rect = trigger.getBoundingClientRect();
64
+ panel.style.top = `${rect.bottom + this.offset()}px`;
65
+ panel.style.left = `${rect.left}px`;
66
+ if (this.matchWidth()) {
67
+ panel.style.width = `${rect.width}px`;
68
+ }
69
+ }
70
+
71
+ private addListeners(): void {
72
+ this.removeListeners();
73
+ this.scrollHandler = () => {
74
+ requestAnimationFrame(() => this.updatePosition());
75
+ };
76
+ this.resizeHandler = () => {
77
+ requestAnimationFrame(() => this.updatePosition());
78
+ };
79
+ document.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true });
80
+ window.addEventListener('resize', this.resizeHandler, { passive: true });
81
+ }
82
+
83
+ private removeListeners(): void {
84
+ if (this.scrollHandler) {
85
+ document.removeEventListener('scroll', this.scrollHandler, { capture: true } as EventListenerOptions);
86
+ this.scrollHandler = null;
87
+ }
88
+ if (this.resizeHandler) {
89
+ window.removeEventListener('resize', this.resizeHandler);
90
+ this.resizeHandler = null;
91
+ }
92
+ }
93
+
94
+ @HostListener('document:click', ['$event'])
95
+ onDocumentClick(event: MouseEvent): void {
96
+ if (this.closeOnOutside() && this.isOpen() && !this.elRef.nativeElement.contains(event.target)) {
97
+ this.close();
98
+ }
99
+ }
100
+
101
+ @HostListener('keydown.escape')
102
+ onEscape(): void {
103
+ if (this.closeOnEscape() && this.isOpen()) {
104
+ this.close();
105
+ }
106
+ }
107
+
108
+ ngOnDestroy(): void {
109
+ this.removeListeners();
110
+ }
111
+ }
112
+
113
+ @Directive({
114
+ selector: '[snyPopoverTrigger]',
115
+ standalone: true,
116
+ host: {
117
+ '(click)': 'popover.toggle()',
118
+ '[attr.aria-expanded]': 'popover.isOpen()',
119
+ 'aria-haspopup': 'dialog',
120
+ },
121
+ })
122
+ export class SnyPopoverTriggerDirective {
123
+ protected readonly popover = inject(SNY_POPOVER);
124
+ private readonly elRef = inject(ElementRef);
125
+
126
+ constructor() {
127
+ this.popover.triggerEl.set(this.elRef.nativeElement);
128
+ }
129
+ }
130
+
131
+ @Directive({
132
+ selector: '[snyPopoverContent]',
133
+ standalone: true,
134
+ host: {
135
+ 'role': 'dialog',
136
+ '[style.display]': 'popover.isOpen() ? "" : "none"',
137
+ '[class]': 'computedClass()',
138
+ },
139
+ })
140
+ export class SnyPopoverContentDirective {
141
+ protected readonly popover = inject(SNY_POPOVER);
142
+ private readonly elRef = inject(ElementRef);
143
+ readonly class = input<string>('');
144
+
145
+ protected readonly computedClass = computed(() =>
146
+ cn(
147
+ 'fixed z-50 rounded-md border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95',
148
+ this.class()
149
+ )
150
+ );
151
+
152
+ constructor() {
153
+ this.popover.panelEl.set(this.elRef.nativeElement);
154
+ }
155
+ }
@@ -0,0 +1,2 @@
1
+ export { SnyTagInputComponent } from './tag-input.component';
2
+ export { tagInputContainerVariants, tagVariants, type TagInputSize } from './tag-input.variants';