@sonny-ui/core 0.1.0-alpha.15 → 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 +1389 -2
- package/fesm2022/sonny-ui-core.mjs.map +1 -1
- package/package.json +1 -1
- 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/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 +206 -3
|
@@ -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
|
+
});
|
|
@@ -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';
|