@sonny-ui/core 0.1.0-alpha.14 → 0.1.0-alpha.16

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 (30) hide show
  1. package/fesm2022/sonny-ui-core.mjs +2257 -68
  2. package/fesm2022/sonny-ui-core.mjs.map +1 -1
  3. package/package.json +1 -1
  4. package/src/lib/calendar/calendar.component.spec.ts +87 -0
  5. package/src/lib/calendar/calendar.component.ts +184 -61
  6. package/src/lib/calendar/calendar.types.ts +24 -0
  7. package/src/lib/calendar/index.ts +6 -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/date-picker/date-picker.component.spec.ts +131 -0
  20. package/src/lib/date-picker/date-picker.component.ts +220 -0
  21. package/src/lib/date-picker/date-picker.variants.ts +17 -0
  22. package/src/lib/date-picker/index.ts +2 -0
  23. package/src/lib/date-range-picker/date-range-picker.component.spec.ts +151 -0
  24. package/src/lib/date-range-picker/date-range-picker.component.ts +340 -0
  25. package/src/lib/date-range-picker/index.ts +1 -0
  26. package/src/lib/otp-input/index.ts +2 -0
  27. package/src/lib/otp-input/otp-input.component.spec.ts +252 -0
  28. package/src/lib/otp-input/otp-input.component.ts +275 -0
  29. package/src/lib/otp-input/otp-input.variants.ts +18 -0
  30. package/types/sonny-ui-core.d.ts +331 -7
@@ -0,0 +1,340 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ ElementRef,
6
+ forwardRef,
7
+ HostListener,
8
+ inject,
9
+ input,
10
+ model,
11
+ OnDestroy,
12
+ signal,
13
+ viewChild,
14
+ } from '@angular/core';
15
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
16
+ import { cn } from '../core/utils/cn';
17
+ import { SnyCalendarComponent } from '../calendar/calendar.component';
18
+ import type { DateRange, DatePickerPreset } from '../calendar/calendar.types';
19
+ import { datePickerTriggerVariants, type DatePickerSize } from '../date-picker/date-picker.variants';
20
+
21
+ @Component({
22
+ selector: 'sny-date-range-picker',
23
+ standalone: true,
24
+ changeDetection: ChangeDetectionStrategy.OnPush,
25
+ imports: [SnyCalendarComponent],
26
+ host: { class: 'relative inline-block w-full' },
27
+ providers: [
28
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyDateRangePickerComponent), multi: true },
29
+ ],
30
+ template: `
31
+ <button
32
+ #triggerEl
33
+ type="button"
34
+ role="combobox"
35
+ [attr.aria-expanded]="open()"
36
+ aria-haspopup="dialog"
37
+ [disabled]="isDisabled()"
38
+ [class]="triggerClass()"
39
+ (click)="toggle()"
40
+ (blur)="onTouched()"
41
+ >
42
+ <span [class]="displayValue() ? 'truncate' : 'text-muted-foreground truncate'">
43
+ {{ displayValue() || placeholder() }}
44
+ </span>
45
+ <div class="flex items-center gap-1 shrink-0">
46
+ @if (clearable() && value()?.start) {
47
+ <button
48
+ type="button"
49
+ class="rounded-sm p-0.5 hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
50
+ (click)="clear($event)"
51
+ aria-label="Clear date range"
52
+ >
53
+ <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="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
54
+ </button>
55
+ }
56
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-muted-foreground"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg>
57
+ </div>
58
+ </button>
59
+
60
+ @if (open()) {
61
+ <div
62
+ #dropdownEl
63
+ role="dialog"
64
+ aria-modal="true"
65
+ aria-label="Choose date range"
66
+ class="fixed z-50 rounded-md border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95"
67
+ >
68
+ <div class="flex flex-col sm:flex-row">
69
+ <!-- Presets sidebar -->
70
+ @if (presets().length > 0) {
71
+ <div class="border-b sm:border-b-0 sm:border-r border-border p-3 space-y-0.5 sm:min-w-[150px]">
72
+ <p class="px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Presets</p>
73
+ @for (preset of presets(); track preset.label) {
74
+ <button
75
+ type="button"
76
+ class="w-full text-left px-3 py-2 text-sm rounded-md hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
77
+ (mousedown)="selectPreset(preset); $event.preventDefault()"
78
+ >
79
+ {{ preset.label }}
80
+ </button>
81
+ }
82
+ </div>
83
+ }
84
+
85
+ <!-- Calendar(s) -->
86
+ <div class="flex flex-col sm:flex-row">
87
+ @if (dualCalendar()) {
88
+ <!-- Left calendar -->
89
+ <div class="p-1">
90
+ <div class="flex items-center justify-between px-3 py-2">
91
+ <button
92
+ type="button"
93
+ class="inline-flex items-center justify-center rounded-md h-8 w-8 hover:bg-accent hover:text-accent-foreground transition-colors"
94
+ (click)="prevMonth()"
95
+ aria-label="Previous month"
96
+ >
97
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
98
+ </button>
99
+ <span class="text-sm font-semibold tracking-tight">{{ leftMonthLabel() }}</span>
100
+ <div class="w-8"></div>
101
+ </div>
102
+ <sny-calendar
103
+ mode="range"
104
+ [(rangeValue)]="internalRange"
105
+ [min]="min()"
106
+ [max]="max()"
107
+ [locale]="locale()"
108
+ [showNavigation]="false"
109
+ [borderless]="true"
110
+ [initialViewDate]="leftViewDate()"
111
+ (rangeValueChange)="onRangeChanged($event)"
112
+ />
113
+ </div>
114
+ <div class="border-t sm:border-t-0 sm:border-l border-border"></div>
115
+ <!-- Right calendar -->
116
+ <div class="p-1">
117
+ <div class="flex items-center justify-between px-3 py-2">
118
+ <div class="w-8"></div>
119
+ <span class="text-sm font-semibold tracking-tight">{{ rightMonthLabel() }}</span>
120
+ <button
121
+ type="button"
122
+ class="inline-flex items-center justify-center rounded-md h-8 w-8 hover:bg-accent hover:text-accent-foreground transition-colors"
123
+ (click)="nextMonth()"
124
+ aria-label="Next month"
125
+ >
126
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
127
+ </button>
128
+ </div>
129
+ <sny-calendar
130
+ mode="range"
131
+ [(rangeValue)]="internalRange"
132
+ [min]="min()"
133
+ [max]="max()"
134
+ [locale]="locale()"
135
+ [showNavigation]="false"
136
+ [borderless]="true"
137
+ [initialViewDate]="rightViewDate()"
138
+ (rangeValueChange)="onRangeChanged($event)"
139
+ />
140
+ </div>
141
+ } @else {
142
+ <!-- Single calendar -->
143
+ <sny-calendar
144
+ mode="range"
145
+ [(rangeValue)]="internalRange"
146
+ [min]="min()"
147
+ [max]="max()"
148
+ [locale]="locale()"
149
+ (rangeValueChange)="onRangeChanged($event)"
150
+ />
151
+ }
152
+ </div>
153
+ </div>
154
+ </div>
155
+ }
156
+ `,
157
+ })
158
+ export class SnyDateRangePickerComponent implements ControlValueAccessor, OnDestroy {
159
+ readonly value = model<DateRange | null>(null);
160
+ readonly placeholder = input('Pick a date range...');
161
+ readonly size = input<DatePickerSize>('md');
162
+ readonly locale = input('en-US');
163
+ readonly dateFormat = input<Intl.DateTimeFormatOptions>({
164
+ month: 'short',
165
+ day: 'numeric',
166
+ year: 'numeric',
167
+ });
168
+ readonly separator = input(' \u2014 ');
169
+ readonly dualCalendar = input(false);
170
+ readonly presets = input<DatePickerPreset[]>([]);
171
+ readonly min = input<Date | undefined>(undefined);
172
+ readonly max = input<Date | undefined>(undefined);
173
+ readonly clearable = input(true);
174
+ readonly disabled = input(false);
175
+ readonly class = input<string>('');
176
+
177
+ readonly open = signal(false);
178
+ readonly internalRange = signal<DateRange | null>(null);
179
+ readonly leftViewDate = signal(new Date());
180
+
181
+ private readonly _disabledByCva = signal(false);
182
+ protected readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
183
+
184
+ private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('triggerEl');
185
+ private readonly dropdownRef = viewChild<ElementRef<HTMLDivElement>>('dropdownEl');
186
+ private readonly elRef = inject(ElementRef);
187
+
188
+ private scrollHandler: (() => void) | null = null;
189
+ private resizeHandler: (() => void) | null = null;
190
+
191
+ private _onChange: (value: DateRange | null) => void = () => {};
192
+ protected onTouched: () => void = () => {};
193
+
194
+ // Computed
195
+ readonly rightViewDate = computed(() => {
196
+ const d = this.leftViewDate();
197
+ return new Date(d.getFullYear(), d.getMonth() + 1, 1);
198
+ });
199
+
200
+ readonly leftMonthLabel = computed(() =>
201
+ this.leftViewDate().toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' })
202
+ );
203
+
204
+ readonly rightMonthLabel = computed(() =>
205
+ this.rightViewDate().toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' })
206
+ );
207
+
208
+ readonly displayValue = computed(() => {
209
+ const r = this.value();
210
+ if (!r?.start) return '';
211
+ const fmt = (d: Date) => d.toLocaleDateString(this.locale(), this.dateFormat());
212
+ if (!r.end) return fmt(r.start) + this.separator() + '...';
213
+ return fmt(r.start) + this.separator() + fmt(r.end);
214
+ });
215
+
216
+ protected readonly triggerClass = computed(() =>
217
+ cn(datePickerTriggerVariants({ size: this.size() }), this.class())
218
+ );
219
+
220
+ // CVA
221
+ writeValue(val: DateRange | null): void {
222
+ this.value.set(val ?? null);
223
+ this.internalRange.set(val ?? null);
224
+ if (val?.start) {
225
+ this.leftViewDate.set(new Date(val.start.getFullYear(), val.start.getMonth(), 1));
226
+ }
227
+ }
228
+
229
+ registerOnChange(fn: (value: DateRange | null) => void): void {
230
+ this._onChange = fn;
231
+ }
232
+
233
+ registerOnTouched(fn: () => void): void {
234
+ this.onTouched = fn;
235
+ }
236
+
237
+ setDisabledState(isDisabled: boolean): void {
238
+ this._disabledByCva.set(isDisabled);
239
+ }
240
+
241
+ // Actions
242
+ onRangeChanged(range: DateRange | null): void {
243
+ this.internalRange.set(range);
244
+ if (range?.start && range?.end) {
245
+ this.value.set(range);
246
+ this._onChange(range);
247
+ setTimeout(() => this.close(), 150);
248
+ }
249
+ }
250
+
251
+ selectPreset(preset: DatePickerPreset): void {
252
+ this.value.set(preset.range);
253
+ this.internalRange.set(preset.range);
254
+ this._onChange(preset.range);
255
+ this.close();
256
+ }
257
+
258
+ clear(event: Event): void {
259
+ event.stopPropagation();
260
+ this.value.set(null);
261
+ this.internalRange.set(null);
262
+ this._onChange(null);
263
+ }
264
+
265
+ prevMonth(): void {
266
+ this.leftViewDate.update((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1));
267
+ }
268
+
269
+ nextMonth(): void {
270
+ this.leftViewDate.update((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1));
271
+ }
272
+
273
+ toggle(): void {
274
+ if (this.open()) {
275
+ this.close();
276
+ } else {
277
+ this.internalRange.set(this.value());
278
+ this.updateDropdownPosition();
279
+ this.open.set(true);
280
+ this.addGlobalListeners();
281
+ setTimeout(() => this.updateDropdownPosition());
282
+ }
283
+ }
284
+
285
+ close(): void {
286
+ this.open.set(false);
287
+ this.removeGlobalListeners();
288
+ }
289
+
290
+ // Positioning
291
+ private updateDropdownPosition(): void {
292
+ const trigger = this.triggerRef()?.nativeElement;
293
+ if (!trigger) return;
294
+ const rect = trigger.getBoundingClientRect();
295
+ const dropdown = this.dropdownRef()?.nativeElement;
296
+ if (dropdown) {
297
+ dropdown.style.top = `${rect.bottom + 4}px`;
298
+ dropdown.style.left = `${rect.left}px`;
299
+ }
300
+ }
301
+
302
+ private addGlobalListeners(): void {
303
+ this.removeGlobalListeners();
304
+ this.scrollHandler = () => {
305
+ requestAnimationFrame(() => this.updateDropdownPosition());
306
+ };
307
+ this.resizeHandler = () => {
308
+ requestAnimationFrame(() => this.updateDropdownPosition());
309
+ };
310
+ document.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true });
311
+ window.addEventListener('resize', this.resizeHandler, { passive: true });
312
+ }
313
+
314
+ private removeGlobalListeners(): void {
315
+ if (this.scrollHandler) {
316
+ document.removeEventListener('scroll', this.scrollHandler, { capture: true } as EventListenerOptions);
317
+ this.scrollHandler = null;
318
+ }
319
+ if (this.resizeHandler) {
320
+ window.removeEventListener('resize', this.resizeHandler);
321
+ this.resizeHandler = null;
322
+ }
323
+ }
324
+
325
+ ngOnDestroy(): void {
326
+ this.removeGlobalListeners();
327
+ }
328
+
329
+ @HostListener('document:click', ['$event'])
330
+ onDocumentClick(event: MouseEvent): void {
331
+ if (!this.elRef.nativeElement.contains(event.target)) {
332
+ this.close();
333
+ }
334
+ }
335
+
336
+ @HostListener('keydown.escape')
337
+ onEscape(): void {
338
+ this.close();
339
+ }
340
+ }
@@ -0,0 +1 @@
1
+ export { SnyDateRangePickerComponent } from './date-range-picker.component';
@@ -0,0 +1,2 @@
1
+ export { SnyOtpInputComponent } from './otp-input.component';
2
+ export { otpCellVariants, type OtpInputSize, type OtpInputType } from './otp-input.variants';
@@ -0,0 +1,252 @@
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 { SnyOtpInputComponent } from './otp-input.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyOtpInputComponent],
9
+ template: `
10
+ <sny-otp-input
11
+ [(value)]="otp"
12
+ [length]="length()"
13
+ [type]="type()"
14
+ [disabled]="disabled()"
15
+ [mask]="mask()"
16
+ [separator]="separator()"
17
+ [status]="status()"
18
+ [autoFocus]="false"
19
+ (completed)="lastCompleted = $event"
20
+ />
21
+ `,
22
+ })
23
+ class TestHostComponent {
24
+ otp = signal('');
25
+ length = signal(6);
26
+ type = signal<'number' | 'alphanumeric'>('number');
27
+ disabled = signal(false);
28
+ mask = signal(false);
29
+ separator = signal<number | null>(null);
30
+ status = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
31
+ lastCompleted: string | null = null;
32
+ }
33
+
34
+ describe('SnyOtpInputComponent', () => {
35
+ let fixture: ComponentFixture<TestHostComponent>;
36
+ let el: HTMLElement;
37
+
38
+ beforeEach(async () => {
39
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
40
+ fixture = TestBed.createComponent(TestHostComponent);
41
+ fixture.detectChanges();
42
+ el = fixture.nativeElement;
43
+ });
44
+
45
+ function getInputs(): HTMLInputElement[] {
46
+ return Array.from(el.querySelectorAll('input'));
47
+ }
48
+
49
+ function typeChar(input: HTMLInputElement, char: string): void {
50
+ input.value = char;
51
+ input.dispatchEvent(new Event('input', { bubbles: true }));
52
+ fixture.detectChanges();
53
+ }
54
+
55
+ it('should render N inputs based on length', () => {
56
+ expect(getInputs().length).toBe(6);
57
+ });
58
+
59
+ it('should render 4 inputs when length is 4', () => {
60
+ fixture.componentInstance.length.set(4);
61
+ fixture.detectChanges();
62
+ expect(getInputs().length).toBe(4);
63
+ });
64
+
65
+ it('should accept numbers when type is number', () => {
66
+ const inputs = getInputs();
67
+ typeChar(inputs[0], '5');
68
+ expect(fixture.componentInstance.otp()).toContain('5');
69
+ });
70
+
71
+ it('should reject letters when type is number', () => {
72
+ const inputs = getInputs();
73
+ typeChar(inputs[0], 'a');
74
+ expect(inputs[0].value).toBe('');
75
+ });
76
+
77
+ it('should accept letters when type is alphanumeric', () => {
78
+ fixture.componentInstance.type.set('alphanumeric');
79
+ fixture.detectChanges();
80
+ const inputs = getInputs();
81
+ typeChar(inputs[0], 'A');
82
+ expect(fixture.componentInstance.otp()).toContain('A');
83
+ });
84
+
85
+ it('should auto-focus next input after typing', () => {
86
+ const inputs = getInputs();
87
+ inputs[0].focus();
88
+ typeChar(inputs[0], '1');
89
+ expect(document.activeElement).toBe(inputs[1]);
90
+ });
91
+
92
+ it('should handle backspace - clear current and move back', () => {
93
+ const inputs = getInputs();
94
+ typeChar(inputs[0], '1');
95
+ typeChar(inputs[1], '2');
96
+
97
+ // Backspace on empty input[2] should move to input[1]
98
+ inputs[2].focus();
99
+ inputs[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
100
+ fixture.detectChanges();
101
+
102
+ // Should clear input[1] and focus it
103
+ expect(document.activeElement).toBe(inputs[1]);
104
+ });
105
+
106
+ it('should handle paste', () => {
107
+ const inputs = getInputs();
108
+ inputs[0].focus();
109
+
110
+ // Create a paste event compatible with test environments
111
+ const pasteEvent = new Event('paste', { bubbles: true }) as any;
112
+ pasteEvent.clipboardData = { getData: () => '123456' };
113
+ inputs[0].dispatchEvent(pasteEvent);
114
+ fixture.detectChanges();
115
+
116
+ expect(fixture.componentInstance.otp()).toBe('123456');
117
+ });
118
+
119
+ it('should emit completed when all digits filled', () => {
120
+ const inputs = getInputs();
121
+ for (let i = 0; i < 6; i++) {
122
+ typeChar(inputs[i], String(i + 1));
123
+ }
124
+ expect(fixture.componentInstance.lastCompleted).toBe('123456');
125
+ });
126
+
127
+ it('should navigate with arrow keys', () => {
128
+ const inputs = getInputs();
129
+ inputs[2].focus();
130
+
131
+ inputs[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
132
+ fixture.detectChanges();
133
+ expect(document.activeElement).toBe(inputs[1]);
134
+
135
+ inputs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
136
+ fixture.detectChanges();
137
+ expect(document.activeElement).toBe(inputs[2]);
138
+ });
139
+
140
+ it('should navigate with Home/End', () => {
141
+ const inputs = getInputs();
142
+ inputs[3].focus();
143
+
144
+ inputs[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
145
+ fixture.detectChanges();
146
+ expect(document.activeElement).toBe(inputs[0]);
147
+
148
+ inputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
149
+ fixture.detectChanges();
150
+ expect(document.activeElement).toBe(inputs[5]);
151
+ });
152
+
153
+ it('should render password inputs when mask is true', () => {
154
+ fixture.componentInstance.mask.set(true);
155
+ fixture.detectChanges();
156
+ const inputs = getInputs();
157
+ expect(inputs[0].type).toBe('password');
158
+ });
159
+
160
+ it('should render separator', () => {
161
+ fixture.componentInstance.separator.set(3);
162
+ fixture.detectChanges();
163
+ const separators = el.querySelectorAll('[aria-hidden="true"]');
164
+ expect(separators.length).toBe(1);
165
+ expect(separators[0].textContent).toContain('—');
166
+ });
167
+
168
+ it('should disable all inputs when disabled', () => {
169
+ fixture.componentInstance.disabled.set(true);
170
+ fixture.detectChanges();
171
+ const inputs = getInputs();
172
+ expect(inputs.every((i) => i.disabled)).toBe(true);
173
+ });
174
+
175
+ it('should have aria-label on each input', () => {
176
+ const inputs = getInputs();
177
+ expect(inputs[0].getAttribute('aria-label')).toBe('Digit 1 of 6');
178
+ expect(inputs[5].getAttribute('aria-label')).toBe('Digit 6 of 6');
179
+ });
180
+
181
+ it('should have autocomplete one-time-code', () => {
182
+ const inputs = getInputs();
183
+ expect(inputs[0].getAttribute('autocomplete')).toBe('one-time-code');
184
+ });
185
+
186
+ it('should disable inputs when status is loading', () => {
187
+ fixture.componentInstance.status.set('loading');
188
+ fixture.detectChanges();
189
+ const inputs = getInputs();
190
+ expect(inputs.every((i) => i.disabled)).toBe(true);
191
+ });
192
+
193
+ it('should apply success styles when status is success', () => {
194
+ fixture.componentInstance.status.set('success');
195
+ fixture.detectChanges();
196
+ const inputs = getInputs();
197
+ expect(inputs[0].className).toContain('border-green-500');
198
+ });
199
+
200
+ it('should apply error styles when status is error', () => {
201
+ fixture.componentInstance.status.set('error');
202
+ fixture.detectChanges();
203
+ const inputs = getInputs();
204
+ expect(inputs[0].className).toContain('border-destructive');
205
+ });
206
+ });
207
+
208
+ // --- Reactive Forms ---
209
+ @Component({
210
+ standalone: true,
211
+ imports: [ReactiveFormsModule, SnyOtpInputComponent],
212
+ template: `<sny-otp-input [formControl]="ctrl" [autoFocus]="false" />`,
213
+ })
214
+ class ReactiveFormHost {
215
+ ctrl = new FormControl('');
216
+ }
217
+
218
+ describe('SnyOtpInputComponent — Reactive Forms', () => {
219
+ let fixture: ComponentFixture<ReactiveFormHost>;
220
+ let el: HTMLElement;
221
+
222
+ beforeEach(async () => {
223
+ await TestBed.configureTestingModule({ imports: [ReactiveFormHost] }).compileComponents();
224
+ fixture = TestBed.createComponent(ReactiveFormHost);
225
+ fixture.detectChanges();
226
+ el = fixture.nativeElement;
227
+ });
228
+
229
+ it('should populate inputs when FormControl value is set', () => {
230
+ fixture.componentInstance.ctrl.setValue('123456');
231
+ fixture.detectChanges();
232
+ const inputs = Array.from(el.querySelectorAll('input')) as HTMLInputElement[];
233
+ expect(inputs[0].value).toBe('1');
234
+ expect(inputs[5].value).toBe('6');
235
+ });
236
+
237
+ it('should update FormControl when user types', () => {
238
+ const inputs = Array.from(el.querySelectorAll('input')) as HTMLInputElement[];
239
+ inputs[0].value = '9';
240
+ inputs[0].dispatchEvent(new Event('input', { bubbles: true }));
241
+ fixture.detectChanges();
242
+
243
+ expect(fixture.componentInstance.ctrl.value).toContain('9');
244
+ });
245
+
246
+ it('should disable via FormControl.disable()', () => {
247
+ fixture.componentInstance.ctrl.disable();
248
+ fixture.detectChanges();
249
+ const inputs = Array.from(el.querySelectorAll('input')) as HTMLInputElement[];
250
+ expect(inputs.every((i) => i.disabled)).toBe(true);
251
+ });
252
+ });