@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,131 @@
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 { SnyDatePickerComponent } from './date-picker.component';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyDatePickerComponent],
9
+ template: `
10
+ <sny-date-picker
11
+ [(value)]="selectedDate"
12
+ [placeholder]="placeholder()"
13
+ [clearable]="clearable()"
14
+ [disabled]="disabled()"
15
+ />
16
+ `,
17
+ })
18
+ class TestHostComponent {
19
+ selectedDate = signal<Date | null>(null);
20
+ placeholder = signal('Pick a date...');
21
+ clearable = signal(true);
22
+ disabled = signal(false);
23
+ }
24
+
25
+ describe('SnyDatePickerComponent', () => {
26
+ let fixture: ComponentFixture<TestHostComponent>;
27
+ let el: HTMLElement;
28
+
29
+ beforeEach(async () => {
30
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
31
+ fixture = TestBed.createComponent(TestHostComponent);
32
+ fixture.detectChanges();
33
+ el = fixture.nativeElement;
34
+ });
35
+
36
+ it('should render trigger with placeholder', () => {
37
+ const trigger = el.querySelector('button');
38
+ expect(trigger?.textContent).toContain('Pick a date...');
39
+ });
40
+
41
+ it('should open dropdown on click', () => {
42
+ const trigger = el.querySelector('button') as HTMLButtonElement;
43
+ trigger.click();
44
+ fixture.detectChanges();
45
+ const calendar = el.querySelector('sny-calendar');
46
+ expect(calendar).not.toBeNull();
47
+ });
48
+
49
+ it('should close dropdown when date is selected', () => {
50
+ const trigger = el.querySelector('button') as HTMLButtonElement;
51
+ trigger.click();
52
+ fixture.detectChanges();
53
+
54
+ const dayBtn = el.querySelectorAll('sny-calendar [role="grid"] button:not([disabled])');
55
+ const btn = Array.from(dayBtn).find((b) => b.textContent?.trim() === '15') as HTMLButtonElement;
56
+ btn?.click();
57
+ fixture.detectChanges();
58
+
59
+ const calendar = el.querySelector('sny-calendar');
60
+ expect(calendar).toBeNull();
61
+ expect(fixture.componentInstance.selectedDate()).not.toBeNull();
62
+ });
63
+
64
+ it('should show clear button when value is set', () => {
65
+ fixture.componentInstance.selectedDate.set(new Date(2026, 2, 15));
66
+ fixture.detectChanges();
67
+ const clearBtn = el.querySelector('[aria-label="Clear date"]');
68
+ expect(clearBtn).not.toBeNull();
69
+ });
70
+
71
+ it('should clear value on clear button click', () => {
72
+ fixture.componentInstance.selectedDate.set(new Date(2026, 2, 15));
73
+ fixture.detectChanges();
74
+ const clearBtn = el.querySelector('[aria-label="Clear date"]') as HTMLButtonElement;
75
+ clearBtn.click();
76
+ fixture.detectChanges();
77
+ expect(fixture.componentInstance.selectedDate()).toBeNull();
78
+ });
79
+
80
+ it('should not open when disabled', () => {
81
+ fixture.componentInstance.disabled.set(true);
82
+ fixture.detectChanges();
83
+ const trigger = el.querySelector('button') as HTMLButtonElement;
84
+ expect(trigger.disabled).toBe(true);
85
+ });
86
+
87
+ it('should display formatted date', () => {
88
+ fixture.componentInstance.selectedDate.set(new Date(2026, 2, 15));
89
+ fixture.detectChanges();
90
+ const trigger = el.querySelector('button');
91
+ expect(trigger?.textContent).toContain('Mar');
92
+ expect(trigger?.textContent).toContain('15');
93
+ expect(trigger?.textContent).toContain('2026');
94
+ });
95
+ });
96
+
97
+ @Component({
98
+ standalone: true,
99
+ imports: [ReactiveFormsModule, SnyDatePickerComponent],
100
+ template: `<sny-date-picker [formControl]="ctrl" />`,
101
+ })
102
+ class ReactiveFormHost {
103
+ ctrl = new FormControl<Date | null>(null);
104
+ }
105
+
106
+ describe('SnyDatePickerComponent — Reactive Forms', () => {
107
+ let fixture: ComponentFixture<ReactiveFormHost>;
108
+ let el: HTMLElement;
109
+
110
+ beforeEach(async () => {
111
+ await TestBed.configureTestingModule({ imports: [ReactiveFormHost] }).compileComponents();
112
+ fixture = TestBed.createComponent(ReactiveFormHost);
113
+ fixture.detectChanges();
114
+ el = fixture.nativeElement;
115
+ });
116
+
117
+ it('should update display when FormControl value changes', () => {
118
+ fixture.componentInstance.ctrl.setValue(new Date(2026, 5, 20));
119
+ fixture.detectChanges();
120
+ const trigger = el.querySelector('button');
121
+ expect(trigger?.textContent).toContain('Jun');
122
+ expect(trigger?.textContent).toContain('20');
123
+ });
124
+
125
+ it('should disable via FormControl.disable()', () => {
126
+ fixture.componentInstance.ctrl.disable();
127
+ fixture.detectChanges();
128
+ const trigger = el.querySelector('button') as HTMLButtonElement;
129
+ expect(trigger.disabled).toBe(true);
130
+ });
131
+ });
@@ -0,0 +1,220 @@
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 { datePickerTriggerVariants, type DatePickerSize } from './date-picker.variants';
19
+
20
+ @Component({
21
+ selector: 'sny-date-picker',
22
+ standalone: true,
23
+ changeDetection: ChangeDetectionStrategy.OnPush,
24
+ imports: [SnyCalendarComponent],
25
+ host: { class: 'relative inline-block w-full' },
26
+ providers: [
27
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyDatePickerComponent), multi: true },
28
+ ],
29
+ template: `
30
+ <button
31
+ #triggerEl
32
+ type="button"
33
+ role="combobox"
34
+ [attr.aria-expanded]="open()"
35
+ aria-haspopup="dialog"
36
+ [disabled]="isDisabled()"
37
+ [class]="triggerClass()"
38
+ (click)="toggle()"
39
+ (blur)="onTouched()"
40
+ >
41
+ <span [class]="displayValue() ? 'truncate' : 'text-muted-foreground truncate'">
42
+ {{ displayValue() || placeholder() }}
43
+ </span>
44
+ <div class="flex items-center gap-1 shrink-0">
45
+ @if (clearable() && value()) {
46
+ <button
47
+ type="button"
48
+ class="rounded-sm p-0.5 hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
49
+ (click)="clear($event)"
50
+ aria-label="Clear date"
51
+ >
52
+ <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>
53
+ </button>
54
+ }
55
+ <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>
56
+ </div>
57
+ </button>
58
+
59
+ @if (open()) {
60
+ <div
61
+ #dropdownEl
62
+ role="dialog"
63
+ aria-modal="true"
64
+ aria-label="Choose date"
65
+ class="fixed z-50 rounded-md border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95"
66
+ >
67
+ <sny-calendar
68
+ [(value)]="internalValue"
69
+ [min]="min()"
70
+ [max]="max()"
71
+ [locale]="locale()"
72
+ (valueChange)="onDateSelected($event)"
73
+ />
74
+ </div>
75
+ }
76
+ `,
77
+ })
78
+ export class SnyDatePickerComponent implements ControlValueAccessor, OnDestroy {
79
+ readonly value = model<Date | null>(null);
80
+ readonly placeholder = input('Pick a date...');
81
+ readonly size = input<DatePickerSize>('md');
82
+ readonly locale = input('en-US');
83
+ readonly dateFormat = input<Intl.DateTimeFormatOptions>({
84
+ month: 'short',
85
+ day: 'numeric',
86
+ year: 'numeric',
87
+ });
88
+ readonly min = input<Date | undefined>(undefined);
89
+ readonly max = input<Date | undefined>(undefined);
90
+ readonly clearable = input(true);
91
+ readonly disabled = input(false);
92
+ readonly class = input<string>('');
93
+
94
+ readonly open = signal(false);
95
+ readonly internalValue = signal<Date | null>(null);
96
+
97
+ private readonly _disabledByCva = signal(false);
98
+ protected readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
99
+
100
+ private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('triggerEl');
101
+ private readonly dropdownRef = viewChild<ElementRef<HTMLDivElement>>('dropdownEl');
102
+ private readonly elRef = inject(ElementRef);
103
+
104
+ private scrollHandler: (() => void) | null = null;
105
+ private resizeHandler: (() => void) | null = null;
106
+
107
+ private _onChange: (value: Date | null) => void = () => {};
108
+ protected onTouched: () => void = () => {};
109
+
110
+ // CVA
111
+ writeValue(val: Date | null): void {
112
+ this.value.set(val ?? null);
113
+ this.internalValue.set(val ?? null);
114
+ }
115
+
116
+ registerOnChange(fn: (value: Date | null) => void): void {
117
+ this._onChange = fn;
118
+ }
119
+
120
+ registerOnTouched(fn: () => void): void {
121
+ this.onTouched = fn;
122
+ }
123
+
124
+ setDisabledState(isDisabled: boolean): void {
125
+ this._disabledByCva.set(isDisabled);
126
+ }
127
+
128
+ // Display
129
+ readonly displayValue = computed(() => {
130
+ const d = this.value();
131
+ if (!d) return '';
132
+ return d.toLocaleDateString(this.locale(), this.dateFormat());
133
+ });
134
+
135
+ protected readonly triggerClass = computed(() =>
136
+ cn(datePickerTriggerVariants({ size: this.size() }), this.class())
137
+ );
138
+
139
+ // Actions
140
+ onDateSelected(date: Date | null): void {
141
+ this.value.set(date);
142
+ this._onChange(date);
143
+ this.close();
144
+ }
145
+
146
+ clear(event: Event): void {
147
+ event.stopPropagation();
148
+ this.value.set(null);
149
+ this.internalValue.set(null);
150
+ this._onChange(null);
151
+ }
152
+
153
+ toggle(): void {
154
+ if (this.open()) {
155
+ this.close();
156
+ } else {
157
+ this.internalValue.set(this.value());
158
+ this.updateDropdownPosition();
159
+ this.open.set(true);
160
+ this.addGlobalListeners();
161
+ setTimeout(() => this.updateDropdownPosition());
162
+ }
163
+ }
164
+
165
+ close(): void {
166
+ this.open.set(false);
167
+ this.removeGlobalListeners();
168
+ }
169
+
170
+ // Positioning (combobox pattern)
171
+ private updateDropdownPosition(): void {
172
+ const trigger = this.triggerRef()?.nativeElement;
173
+ if (!trigger) return;
174
+ const rect = trigger.getBoundingClientRect();
175
+ const dropdown = this.dropdownRef()?.nativeElement;
176
+ if (dropdown) {
177
+ dropdown.style.top = `${rect.bottom + 4}px`;
178
+ dropdown.style.left = `${rect.left}px`;
179
+ }
180
+ }
181
+
182
+ private addGlobalListeners(): void {
183
+ this.removeGlobalListeners();
184
+ this.scrollHandler = () => {
185
+ requestAnimationFrame(() => this.updateDropdownPosition());
186
+ };
187
+ this.resizeHandler = () => {
188
+ requestAnimationFrame(() => this.updateDropdownPosition());
189
+ };
190
+ document.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true });
191
+ window.addEventListener('resize', this.resizeHandler, { passive: true });
192
+ }
193
+
194
+ private removeGlobalListeners(): void {
195
+ if (this.scrollHandler) {
196
+ document.removeEventListener('scroll', this.scrollHandler, { capture: true } as EventListenerOptions);
197
+ this.scrollHandler = null;
198
+ }
199
+ if (this.resizeHandler) {
200
+ window.removeEventListener('resize', this.resizeHandler);
201
+ this.resizeHandler = null;
202
+ }
203
+ }
204
+
205
+ ngOnDestroy(): void {
206
+ this.removeGlobalListeners();
207
+ }
208
+
209
+ @HostListener('document:click', ['$event'])
210
+ onDocumentClick(event: MouseEvent): void {
211
+ if (!this.elRef.nativeElement.contains(event.target)) {
212
+ this.close();
213
+ }
214
+ }
215
+
216
+ @HostListener('keydown.escape')
217
+ onEscape(): void {
218
+ this.close();
219
+ }
220
+ }
@@ -0,0 +1,17 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const datePickerTriggerVariants = cva(
4
+ 'inline-flex w-full items-center justify-between whitespace-nowrap rounded-sm border border-border bg-background px-3 py-2 text-sm ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
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 DatePickerSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,2 @@
1
+ export { SnyDatePickerComponent } from './date-picker.component';
2
+ export { datePickerTriggerVariants, type DatePickerSize } from './date-picker.variants';
@@ -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 { SnyDateRangePickerComponent } from './date-range-picker.component';
5
+ import type { DateRange, DatePickerPreset } from '../calendar/calendar.types';
6
+
7
+ @Component({
8
+ standalone: true,
9
+ imports: [SnyDateRangePickerComponent],
10
+ template: `
11
+ <sny-date-range-picker
12
+ [(value)]="range"
13
+ [placeholder]="placeholder()"
14
+ [dualCalendar]="dualCalendar()"
15
+ [presets]="presets()"
16
+ [clearable]="clearable()"
17
+ />
18
+ `,
19
+ })
20
+ class TestHostComponent {
21
+ range = signal<DateRange | null>(null);
22
+ placeholder = signal('Pick a date range...');
23
+ dualCalendar = signal(false);
24
+ clearable = signal(true);
25
+ presets = signal<DatePickerPreset[]>([]);
26
+ }
27
+
28
+ describe('SnyDateRangePickerComponent', () => {
29
+ let fixture: ComponentFixture<TestHostComponent>;
30
+ let el: HTMLElement;
31
+
32
+ beforeEach(async () => {
33
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
34
+ fixture = TestBed.createComponent(TestHostComponent);
35
+ fixture.detectChanges();
36
+ el = fixture.nativeElement;
37
+ });
38
+
39
+ it('should render trigger with placeholder', () => {
40
+ const trigger = el.querySelector('button');
41
+ expect(trigger?.textContent).toContain('Pick a date range...');
42
+ });
43
+
44
+ it('should open dropdown with single calendar by default', () => {
45
+ const trigger = el.querySelector('button') as HTMLButtonElement;
46
+ trigger.click();
47
+ fixture.detectChanges();
48
+ const calendars = el.querySelectorAll('sny-calendar');
49
+ expect(calendars.length).toBe(1);
50
+ });
51
+
52
+ it('should render dual calendars when dualCalendar is true', () => {
53
+ fixture.componentInstance.dualCalendar.set(true);
54
+ fixture.detectChanges();
55
+ const trigger = el.querySelector('button') as HTMLButtonElement;
56
+ trigger.click();
57
+ fixture.detectChanges();
58
+ const calendars = el.querySelectorAll('sny-calendar');
59
+ expect(calendars.length).toBe(2);
60
+ });
61
+
62
+ it('should display formatted range', () => {
63
+ fixture.componentInstance.range.set({
64
+ start: new Date(2026, 2, 10),
65
+ end: new Date(2026, 2, 20),
66
+ });
67
+ fixture.detectChanges();
68
+ const trigger = el.querySelector('button');
69
+ expect(trigger?.textContent).toContain('Mar');
70
+ expect(trigger?.textContent).toContain('10');
71
+ expect(trigger?.textContent).toContain('20');
72
+ });
73
+
74
+ it('should render presets sidebar when presets provided', () => {
75
+ fixture.componentInstance.presets.set([
76
+ { label: 'Last 7 days', range: { start: new Date(), end: new Date() } },
77
+ { label: 'Last 30 days', range: { start: new Date(), end: new Date() } },
78
+ ]);
79
+ fixture.detectChanges();
80
+ const trigger = el.querySelector('button') as HTMLButtonElement;
81
+ trigger.click();
82
+ fixture.detectChanges();
83
+ const presetBtns = el.querySelectorAll('[class*="hover:bg-accent"]');
84
+ const presetLabels = Array.from(presetBtns).map((b) => b.textContent?.trim());
85
+ expect(presetLabels).toContain('Last 7 days');
86
+ expect(presetLabels).toContain('Last 30 days');
87
+ });
88
+
89
+ it('should clear value on clear button click', () => {
90
+ fixture.componentInstance.range.set({
91
+ start: new Date(2026, 2, 10),
92
+ end: new Date(2026, 2, 20),
93
+ });
94
+ fixture.detectChanges();
95
+ const clearBtn = el.querySelector('[aria-label="Clear date range"]') as HTMLButtonElement;
96
+ clearBtn.click();
97
+ fixture.detectChanges();
98
+ expect(fixture.componentInstance.range()).toBeNull();
99
+ });
100
+
101
+ it('should show navigation arrows in dual mode', () => {
102
+ fixture.componentInstance.dualCalendar.set(true);
103
+ fixture.detectChanges();
104
+ const trigger = el.querySelector('button') as HTMLButtonElement;
105
+ trigger.click();
106
+ fixture.detectChanges();
107
+ const prevBtn = el.querySelector('[aria-label="Previous month"]');
108
+ const nextBtn = el.querySelector('[aria-label="Next month"]');
109
+ expect(prevBtn).not.toBeNull();
110
+ expect(nextBtn).not.toBeNull();
111
+ });
112
+ });
113
+
114
+ @Component({
115
+ standalone: true,
116
+ imports: [ReactiveFormsModule, SnyDateRangePickerComponent],
117
+ template: `<sny-date-range-picker [formControl]="ctrl" />`,
118
+ })
119
+ class ReactiveFormHost {
120
+ ctrl = new FormControl<DateRange | null>(null);
121
+ }
122
+
123
+ describe('SnyDateRangePickerComponent — Reactive Forms', () => {
124
+ let fixture: ComponentFixture<ReactiveFormHost>;
125
+ let el: HTMLElement;
126
+
127
+ beforeEach(async () => {
128
+ await TestBed.configureTestingModule({ imports: [ReactiveFormHost] }).compileComponents();
129
+ fixture = TestBed.createComponent(ReactiveFormHost);
130
+ fixture.detectChanges();
131
+ el = fixture.nativeElement;
132
+ });
133
+
134
+ it('should update display when FormControl value changes', () => {
135
+ fixture.componentInstance.ctrl.setValue({
136
+ start: new Date(2026, 5, 10),
137
+ end: new Date(2026, 5, 20),
138
+ });
139
+ fixture.detectChanges();
140
+ const trigger = el.querySelector('button');
141
+ expect(trigger?.textContent).toContain('Jun');
142
+ expect(trigger?.textContent).toContain('10');
143
+ });
144
+
145
+ it('should disable via FormControl.disable()', () => {
146
+ fixture.componentInstance.ctrl.disable();
147
+ fixture.detectChanges();
148
+ const trigger = el.querySelector('button') as HTMLButtonElement;
149
+ expect(trigger.disabled).toBe(true);
150
+ });
151
+ });