@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sonny-ui/core",
3
- "version": "0.1.0-alpha.14",
3
+ "version": "0.1.0-alpha.16",
4
4
  "description": "Angular UI component library inspired by shadcn/ui — signals, zoneless, Tailwind CSS v4",
5
5
  "peerDependencies": {
6
6
  "@angular/common": "^21.0.0",
@@ -103,3 +103,90 @@ describe('SnyCalendarComponent — Reactive Forms', () => {
103
103
  expect(allDisabled).toBe(true);
104
104
  });
105
105
  });
106
+
107
+ // --- Range Mode Tests ---
108
+
109
+ import type { DateRange } from './calendar.types';
110
+
111
+ @Component({
112
+ standalone: true,
113
+ imports: [SnyCalendarComponent],
114
+ template: `<sny-calendar mode="range" [(rangeValue)]="range" />`,
115
+ })
116
+ class RangeTestHost {
117
+ range = signal<DateRange | null>(null);
118
+ }
119
+
120
+ describe('SnyCalendarComponent — Range Mode', () => {
121
+ let fixture: ComponentFixture<RangeTestHost>;
122
+ let host: HTMLElement;
123
+
124
+ beforeEach(async () => {
125
+ await TestBed.configureTestingModule({ imports: [RangeTestHost] }).compileComponents();
126
+ fixture = TestBed.createComponent(RangeTestHost);
127
+ fixture.detectChanges();
128
+ host = fixture.nativeElement.querySelector('sny-calendar');
129
+ });
130
+
131
+ function clickDay(dayNum: string): void {
132
+ const buttons = host.querySelectorAll('[role="grid"] button:not([disabled])');
133
+ const btn = Array.from(buttons).find((b) => b.textContent?.trim() === dayNum) as HTMLButtonElement;
134
+ btn?.click();
135
+ fixture.detectChanges();
136
+ }
137
+
138
+ it('should render 42 buttons in range mode', () => {
139
+ const buttons = host.querySelectorAll('[role="grid"] button');
140
+ expect(buttons.length).toBe(42);
141
+ });
142
+
143
+ it('should set range start on first click', () => {
144
+ clickDay('10');
145
+ const range = fixture.componentInstance.range();
146
+ expect(range).not.toBeNull();
147
+ expect(range!.start).not.toBeNull();
148
+ expect(range!.start!.getDate()).toBe(10);
149
+ expect(range!.end).toBeNull();
150
+ });
151
+
152
+ it('should set range end on second click', () => {
153
+ clickDay('10');
154
+ clickDay('20');
155
+ const range = fixture.componentInstance.range();
156
+ expect(range!.start!.getDate()).toBe(10);
157
+ expect(range!.end!.getDate()).toBe(20);
158
+ });
159
+
160
+ it('should swap if second click is before start', () => {
161
+ clickDay('20');
162
+ clickDay('5');
163
+ const range = fixture.componentInstance.range();
164
+ expect(range!.start!.getDate()).toBe(5);
165
+ expect(range!.end!.getDate()).toBe(20);
166
+ });
167
+
168
+ it('should reset range on third click', () => {
169
+ clickDay('10');
170
+ clickDay('20');
171
+ clickDay('15');
172
+ const range = fixture.componentInstance.range();
173
+ expect(range!.start!.getDate()).toBe(15);
174
+ expect(range!.end).toBeNull();
175
+ });
176
+
177
+ it('should have range highlight classes when range is set', () => {
178
+ clickDay('10');
179
+ clickDay('15');
180
+ fixture.detectChanges();
181
+ const buttons = host.querySelectorAll('[role="grid"] button');
182
+ const classes = Array.from(buttons).map((b) => b.className);
183
+ const hasRangeStyle = classes.some((c) => c.includes('bg-primary/15') || c.includes('rounded-l-none') || c.includes('rounded-r-none'));
184
+ expect(hasRangeStyle).toBe(true);
185
+ });
186
+
187
+ it('should not affect single mode behavior', () => {
188
+ // This test uses the basic TestHostComponent which defaults to single mode
189
+ // Regression test: existing single mode tests above should still pass
190
+ expect(true).toBe(true);
191
+ });
192
+ });
@@ -1,58 +1,70 @@
1
- import { ChangeDetectionStrategy, Component, computed, forwardRef, input, model, signal } from '@angular/core';
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ forwardRef,
6
+ input,
7
+ linkedSignal,
8
+ model,
9
+ signal,
10
+ } from '@angular/core';
2
11
  import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
3
12
  import { cn } from '../core/utils/cn';
4
-
5
- interface CalendarDay {
6
- date: Date;
7
- day: number;
8
- isCurrentMonth: boolean;
9
- isToday: boolean;
10
- isSelected: boolean;
11
- isDisabled: boolean;
12
- }
13
+ import type { CalendarDay, CalendarMode, DateRange } from './calendar.types';
13
14
 
14
15
  @Component({
15
16
  selector: 'sny-calendar',
16
17
  standalone: true,
17
18
  changeDetection: ChangeDetectionStrategy.OnPush,
18
19
  host: {
19
- '[class]': '"inline-block p-3 rounded-md border bg-background"',
20
+ '[class]': 'hostClass()',
20
21
  '(keydown)': 'onKeydown($event)',
22
+ 'role': 'application',
23
+ 'aria-label': 'Calendar',
21
24
  },
22
25
  providers: [
23
26
  { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyCalendarComponent), multi: true },
24
27
  ],
25
28
  template: `
26
- <div class="flex items-center justify-between mb-4">
27
- <button
28
- class="inline-flex items-center justify-center rounded-md text-sm h-7 w-7 hover:bg-accent"
29
- (click)="prevMonth()"
30
- aria-label="Previous month"
31
- >
32
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
33
- </button>
34
- <span class="text-sm font-medium">{{ monthYearLabel() }}</span>
35
- <button
36
- class="inline-flex items-center justify-center rounded-md text-sm h-7 w-7 hover:bg-accent"
37
- (click)="nextMonth()"
38
- aria-label="Next month"
39
- >
40
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
41
- </button>
42
- </div>
29
+ @if (showNavigation()) {
30
+ <div class="flex items-center justify-between mb-3">
31
+ <button
32
+ type="button"
33
+ class="inline-flex items-center justify-center rounded-md h-8 w-8 hover:bg-accent hover:text-accent-foreground transition-colors"
34
+ (click)="prevMonth()"
35
+ aria-label="Previous month"
36
+ >
37
+ <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>
38
+ </button>
39
+ <span class="text-sm font-semibold tracking-tight">{{ monthYearLabel() }}</span>
40
+ <button
41
+ type="button"
42
+ class="inline-flex items-center justify-center rounded-md h-8 w-8 hover:bg-accent hover:text-accent-foreground transition-colors"
43
+ (click)="nextMonth()"
44
+ aria-label="Next month"
45
+ >
46
+ <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>
47
+ </button>
48
+ </div>
49
+ }
43
50
 
44
- <div role="grid" aria-label="Calendar" class="grid grid-cols-7 gap-0">
51
+ <div role="grid" class="grid grid-cols-7 gap-1">
45
52
  @for (dayName of weekDays; track dayName) {
46
- <div class="text-center text-xs text-muted-foreground font-medium py-1">{{ dayName }}</div>
53
+ <div class="text-center text-xs text-muted-foreground font-medium h-9 flex items-center justify-center" role="columnheader">{{ dayName }}</div>
47
54
  }
48
55
  @for (day of days(); track day.date.getTime()) {
49
56
  <button
57
+ type="button"
50
58
  [class]="dayClass(day)"
51
59
  [disabled]="day.isDisabled"
52
- [attr.aria-selected]="day.isSelected || null"
60
+ [attr.aria-selected]="day.isSelected || day.isRangeStart || day.isRangeEnd || null"
53
61
  [attr.aria-current]="day.isToday ? 'date' : null"
54
62
  [attr.aria-disabled]="day.isDisabled || null"
55
- (click)="selectDate(day.date)"
63
+ [attr.aria-label]="day.date.toLocaleDateString(locale(), { month: 'long', day: 'numeric', year: 'numeric' })"
64
+ role="gridcell"
65
+ (click)="onDayClick(day.date)"
66
+ (mouseenter)="onDayHover(day.date)"
67
+ (mouseleave)="onDayHover(null)"
56
68
  >
57
69
  {{ day.day }}
58
70
  </button>
@@ -61,28 +73,53 @@ interface CalendarDay {
61
73
  `,
62
74
  })
63
75
  export class SnyCalendarComponent implements ControlValueAccessor {
76
+ // Existing inputs (backwards compatible)
64
77
  readonly value = model<Date | null>(null);
65
78
  readonly min = input<Date | undefined>(undefined);
66
79
  readonly max = input<Date | undefined>(undefined);
67
80
  readonly locale = input('en-US');
68
81
  readonly class = input<string>('');
69
82
 
70
- private readonly _disabledByCva = signal(false);
83
+ // Range mode inputs
84
+ readonly mode = input<CalendarMode>('single');
85
+ readonly rangeValue = model<DateRange | null>(null);
86
+ readonly showNavigation = input(true);
87
+ readonly initialViewDate = input<Date | undefined>(undefined);
88
+ readonly borderless = input(false);
71
89
 
72
- readonly viewDate = signal(new Date());
90
+ readonly hostClass = computed(() =>
91
+ this.borderless()
92
+ ? 'inline-block p-3 bg-background'
93
+ : 'inline-block p-4 rounded-md border border-border bg-background'
94
+ );
95
+
96
+ // Internal state
97
+ private readonly _disabledByCva = signal(false);
98
+ readonly hoveredDate = signal<Date | null>(null);
99
+ readonly viewDate = linkedSignal(() => this.initialViewDate() ?? new Date());
73
100
  readonly weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
74
101
 
75
- private _onChange: (value: Date | null) => void = () => {};
102
+ // CVA
103
+ private _onChange: (value: unknown) => void = () => {};
76
104
  protected onTouched: () => void = () => {};
77
105
 
78
- writeValue(val: Date | null): void {
79
- this.value.set(val ?? null);
80
- if (val) {
81
- this.viewDate.set(new Date(val.getFullYear(), val.getMonth(), 1));
106
+ writeValue(val: unknown): void {
107
+ if (this.mode() === 'range') {
108
+ this.rangeValue.set((val as DateRange) ?? null);
109
+ const range = val as DateRange | null;
110
+ if (range?.start) {
111
+ this.viewDate.set(new Date(range.start.getFullYear(), range.start.getMonth(), 1));
112
+ }
113
+ } else {
114
+ this.value.set((val as Date) ?? null);
115
+ if (val) {
116
+ const d = val as Date;
117
+ this.viewDate.set(new Date(d.getFullYear(), d.getMonth(), 1));
118
+ }
82
119
  }
83
120
  }
84
121
 
85
- registerOnChange(fn: (value: Date | null) => void): void {
122
+ registerOnChange(fn: (value: unknown) => void): void {
86
123
  this._onChange = fn;
87
124
  }
88
125
 
@@ -94,6 +131,7 @@ export class SnyCalendarComponent implements ControlValueAccessor {
94
131
  this._disabledByCva.set(isDisabled);
95
132
  }
96
133
 
134
+ // Computed
97
135
  readonly monthYearLabel = computed(() => {
98
136
  const d = this.viewDate();
99
137
  return d.toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' });
@@ -105,6 +143,8 @@ export class SnyCalendarComponent implements ControlValueAccessor {
105
143
  const month = view.getMonth();
106
144
  const today = new Date();
107
145
  const selected = this.value();
146
+ const rangeVal = this.mode() === 'range' ? this.rangeValue() : null;
147
+ const hovered = this.hoveredDate();
108
148
  const minDate = this.min();
109
149
  const maxDate = this.max();
110
150
 
@@ -115,44 +155,78 @@ export class SnyCalendarComponent implements ControlValueAccessor {
115
155
 
116
156
  const days: CalendarDay[] = [];
117
157
 
118
- // Previous month
119
158
  for (let i = startDay - 1; i >= 0; i--) {
120
159
  const date = new Date(year, month - 1, daysInPrevMonth - i);
121
- days.push(this.createDay(date, false, today, selected, minDate, maxDate));
160
+ days.push(this.createDay(date, false, today, selected, rangeVal, hovered, minDate, maxDate));
122
161
  }
123
162
 
124
- // Current month
125
163
  for (let d = 1; d <= daysInMonth; d++) {
126
164
  const date = new Date(year, month, d);
127
- days.push(this.createDay(date, true, today, selected, minDate, maxDate));
165
+ days.push(this.createDay(date, true, today, selected, rangeVal, hovered, minDate, maxDate));
128
166
  }
129
167
 
130
- // Next month fill
131
168
  const remaining = 42 - days.length;
132
169
  for (let d = 1; d <= remaining; d++) {
133
170
  const date = new Date(year, month + 1, d);
134
- days.push(this.createDay(date, false, today, selected, minDate, maxDate));
171
+ days.push(this.createDay(date, false, today, selected, rangeVal, hovered, minDate, maxDate));
135
172
  }
136
173
 
137
174
  return days;
138
175
  });
139
176
 
177
+ // Navigation
140
178
  prevMonth(): void {
141
- this.viewDate.update((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1));
179
+ this.viewDate.set(new Date(
180
+ this.viewDate().getFullYear(),
181
+ this.viewDate().getMonth() - 1,
182
+ 1,
183
+ ));
142
184
  }
143
185
 
144
186
  nextMonth(): void {
145
- this.viewDate.update((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1));
187
+ this.viewDate.set(new Date(
188
+ this.viewDate().getFullYear(),
189
+ this.viewDate().getMonth() + 1,
190
+ 1,
191
+ ));
146
192
  }
147
193
 
148
- selectDate(date: Date): void {
149
- this.value.set(date);
150
- this._onChange(date);
194
+ // Click handler
195
+ onDayClick(date: Date): void {
196
+ if (this.mode() === 'single') {
197
+ this.value.set(date);
198
+ this._onChange(date);
199
+ this.onTouched();
200
+ return;
201
+ }
202
+
203
+ // Range mode
204
+ const current = this.rangeValue();
205
+ if (!current?.start || (current.start && current.end)) {
206
+ this.rangeValue.set({ start: date, end: null });
207
+ } else {
208
+ const start = current.start;
209
+ if (date < start) {
210
+ this.rangeValue.set({ start: date, end: start });
211
+ } else if (this.isSameDay(date, start)) {
212
+ this.rangeValue.set({ start: date, end: date });
213
+ } else {
214
+ this.rangeValue.set({ start, end: date });
215
+ }
216
+ }
217
+ this._onChange(this.rangeValue());
151
218
  this.onTouched();
152
219
  }
153
220
 
221
+ // Hover handler
222
+ onDayHover(date: Date | null): void {
223
+ if (this.mode() === 'range') {
224
+ this.hoveredDate.set(date);
225
+ }
226
+ }
227
+
228
+ // Keyboard
154
229
  onKeydown(event: KeyboardEvent): void {
155
- // Simplified keyboard navigation
156
230
  switch (event.key) {
157
231
  case 'ArrowLeft':
158
232
  event.preventDefault();
@@ -173,17 +247,35 @@ export class SnyCalendarComponent implements ControlValueAccessor {
173
247
  }
174
248
  }
175
249
 
250
+ // Styling
176
251
  dayClass(day: CalendarDay): string {
252
+ const isEndpoint = day.isRangeStart || day.isRangeEnd;
177
253
  return cn(
178
- 'inline-flex items-center justify-center rounded-md text-sm h-8 w-8 transition-colors',
179
- day.isCurrentMonth ? 'text-foreground' : 'text-muted-foreground/50',
180
- day.isToday && !day.isSelected && 'bg-accent font-bold',
181
- day.isSelected && 'bg-primary text-primary-foreground',
182
- day.isDisabled && 'opacity-50 cursor-not-allowed',
183
- !day.isDisabled && !day.isSelected && 'hover:bg-accent cursor-pointer'
254
+ 'inline-flex items-center justify-center text-sm h-9 w-9 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
255
+ // Shape
256
+ day.isRangeStart && !day.isRangeEnd ? 'rounded-l-md rounded-r-none' :
257
+ day.isRangeEnd && !day.isRangeStart ? 'rounded-r-md rounded-l-none' :
258
+ day.isInRange || day.isRangePreview ? 'rounded-none' :
259
+ 'rounded-md',
260
+ // Base text color
261
+ !day.isCurrentMonth && 'text-muted-foreground/40',
262
+ day.isCurrentMonth && !day.isSelected && !isEndpoint && 'text-foreground',
263
+ // Today indicator
264
+ day.isToday && !day.isSelected && !isEndpoint && 'bg-accent text-accent-foreground font-semibold',
265
+ // Single selected
266
+ day.isSelected && this.mode() === 'single' && 'bg-primary text-primary-foreground font-semibold shadow-sm',
267
+ // Range endpoints
268
+ isEndpoint && 'bg-primary text-primary-foreground font-semibold shadow-sm',
269
+ // Range band
270
+ day.isInRange && 'bg-primary/10 text-foreground',
271
+ day.isRangePreview && 'bg-primary/5 text-foreground',
272
+ // States
273
+ day.isDisabled && 'opacity-40 cursor-not-allowed pointer-events-none',
274
+ !day.isDisabled && !day.isSelected && !isEndpoint && 'hover:bg-accent hover:text-accent-foreground cursor-pointer',
184
275
  );
185
276
  }
186
277
 
278
+ // Private helpers
187
279
  private navigateDays(offset: number): void {
188
280
  const current = this.value() ?? new Date();
189
281
  const next = new Date(current);
@@ -198,16 +290,47 @@ export class SnyCalendarComponent implements ControlValueAccessor {
198
290
  isCurrentMonth: boolean,
199
291
  today: Date,
200
292
  selected: Date | null,
293
+ rangeVal: DateRange | null,
294
+ hoveredDate: Date | null,
201
295
  minDate: Date | undefined,
202
- maxDate: Date | undefined
296
+ maxDate: Date | undefined,
203
297
  ): CalendarDay {
204
298
  const isToday = this.isSameDay(date, today);
205
299
  const isSelected = selected ? this.isSameDay(date, selected) : false;
206
300
  const isDisabled =
207
301
  this._disabledByCva() ||
208
- (minDate ? date < minDate : false) || (maxDate ? date > maxDate : false);
302
+ (minDate ? date < minDate : false) ||
303
+ (maxDate ? date > maxDate : false);
304
+
305
+ let isRangeStart = false;
306
+ let isRangeEnd = false;
307
+ let isInRange = false;
308
+ let isRangePreview = false;
309
+
310
+ if (rangeVal) {
311
+ const { start, end } = rangeVal;
312
+ if (start) isRangeStart = this.isSameDay(date, start);
313
+ if (end) isRangeEnd = this.isSameDay(date, end);
314
+ if (start && end) {
315
+ isInRange = date > start && date < end && !isRangeStart && !isRangeEnd;
316
+ }
317
+ // Preview: start set, no end yet, user hovering
318
+ if (start && !end && hoveredDate && !this.isSameDay(hoveredDate, start)) {
319
+ const previewStart = hoveredDate > start ? start : hoveredDate;
320
+ const previewEnd = hoveredDate > start ? hoveredDate : start;
321
+ if (date > previewStart && date < previewEnd) {
322
+ isRangePreview = true;
323
+ }
324
+ if (this.isSameDay(date, hoveredDate) && !isRangeStart) {
325
+ isRangePreview = true;
326
+ }
327
+ }
328
+ }
209
329
 
210
- return { date, day: date.getDate(), isCurrentMonth, isToday, isSelected, isDisabled };
330
+ return {
331
+ date, day: date.getDate(), isCurrentMonth, isToday, isSelected, isDisabled,
332
+ isRangeStart, isRangeEnd, isInRange, isRangePreview,
333
+ };
211
334
  }
212
335
 
213
336
  private isSameDay(a: Date, b: Date): boolean {
@@ -0,0 +1,24 @@
1
+ export interface DateRange {
2
+ start: Date | null;
3
+ end: Date | null;
4
+ }
5
+
6
+ export interface CalendarDay {
7
+ date: Date;
8
+ day: number;
9
+ isCurrentMonth: boolean;
10
+ isToday: boolean;
11
+ isSelected: boolean;
12
+ isDisabled: boolean;
13
+ isRangeStart: boolean;
14
+ isRangeEnd: boolean;
15
+ isInRange: boolean;
16
+ isRangePreview: boolean;
17
+ }
18
+
19
+ export type CalendarMode = 'single' | 'range';
20
+
21
+ export interface DatePickerPreset {
22
+ label: string;
23
+ range: DateRange;
24
+ }
@@ -1 +1,7 @@
1
1
  export { SnyCalendarComponent } from './calendar.component';
2
+ export type {
3
+ DateRange,
4
+ CalendarDay,
5
+ CalendarMode,
6
+ DatePickerPreset,
7
+ } from './calendar.types';