@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.
- package/fesm2022/sonny-ui-core.mjs +2257 -68
- package/fesm2022/sonny-ui-core.mjs.map +1 -1
- package/package.json +1 -1
- package/src/lib/calendar/calendar.component.spec.ts +87 -0
- package/src/lib/calendar/calendar.component.ts +184 -61
- package/src/lib/calendar/calendar.types.ts +24 -0
- package/src/lib/calendar/index.ts +6 -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/date-picker/date-picker.component.spec.ts +131 -0
- package/src/lib/date-picker/date-picker.component.ts +220 -0
- package/src/lib/date-picker/date-picker.variants.ts +17 -0
- package/src/lib/date-picker/index.ts +2 -0
- package/src/lib/date-range-picker/date-range-picker.component.spec.ts +151 -0
- package/src/lib/date-range-picker/date-range-picker.component.ts +340 -0
- package/src/lib/date-range-picker/index.ts +1 -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/types/sonny-ui-core.d.ts +331 -7
|
@@ -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';
|