@mimi-ng/cli 0.2.0 → 0.4.0
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/README.md +35 -2
- package/dist/init/css.js +1 -1
- package/dist/init/index.js +25 -0
- package/dist/init/paths.js +20 -0
- package/dist/registry.json +385 -14
- package/dist/templates/blocks/login-01/index.ts +1 -0
- package/dist/templates/blocks/login-01/login-01.html +174 -0
- package/dist/templates/blocks/login-01/login-01.ts +105 -0
- package/dist/templates/blocks/login-02/index.ts +1 -0
- package/dist/templates/blocks/login-02/login-02.html +186 -0
- package/dist/templates/blocks/login-02/login-02.ts +92 -0
- package/dist/templates/blocks/otp-01/index.ts +1 -0
- package/dist/templates/blocks/otp-01/otp-01.html +166 -0
- package/dist/templates/blocks/otp-01/otp-01.ts +212 -0
- package/dist/templates/blocks/pricing-01/index.ts +1 -0
- package/dist/templates/blocks/pricing-01/pricing-01.html +173 -0
- package/dist/templates/blocks/pricing-01/pricing-01.ts +178 -0
- package/dist/templates/blocks/settings-01/index.ts +1 -0
- package/dist/templates/blocks/settings-01/settings-01.html +424 -0
- package/dist/templates/blocks/settings-01/settings-01.ts +382 -0
- package/dist/templates/blocks/signup-01/index.ts +1 -0
- package/dist/templates/blocks/signup-01/signup-01.html +332 -0
- package/dist/templates/blocks/signup-01/signup-01.ts +176 -0
- package/dist/templates/components/calendar/calendar-texts.ts +72 -0
- package/dist/templates/components/calendar/calendar.ts +588 -0
- package/dist/templates/components/calendar/dates.ts +181 -0
- package/dist/templates/components/calendar/index.ts +3 -0
- package/dist/templates/components/chip/chip-texts.ts +40 -0
- package/dist/templates/components/chip/chip.ts +116 -0
- package/dist/templates/components/chip/index.ts +2 -0
- package/dist/templates/components/color-picker/color-picker-texts.ts +57 -0
- package/dist/templates/components/color-picker/color-picker.ts +718 -0
- package/dist/templates/components/color-picker/color.ts +157 -0
- package/dist/templates/components/color-picker/index.ts +3 -0
- package/dist/templates/components/combobox/combobox-texts.ts +70 -0
- package/dist/templates/components/combobox/combobox.ts +798 -0
- package/dist/templates/components/combobox/index.ts +2 -0
- package/dist/templates/components/date-picker/date-picker.ts +240 -0
- package/dist/templates/components/date-picker/index.ts +3 -0
- package/dist/templates/components/date-range/date-range.ts +308 -0
- package/dist/templates/components/date-range/index.ts +1 -0
- package/dist/templates/components/file-upload/file-upload-texts.ts +77 -0
- package/dist/templates/components/file-upload/file-upload.ts +698 -0
- package/dist/templates/components/file-upload/index.ts +2 -0
- package/dist/templates/components/input-mask/index.ts +1 -0
- package/dist/templates/components/input-mask/input-mask.ts +319 -0
- package/dist/templates/components/input-otp/index.ts +1 -0
- package/dist/templates/components/input-otp/input-otp.ts +212 -0
- package/dist/templates/components/number-input/index.ts +2 -0
- package/dist/templates/components/number-input/number-input-texts.ts +46 -0
- package/dist/templates/components/number-input/number-input.ts +450 -0
- package/dist/templates/components/option-list/index.ts +11 -0
- package/dist/templates/components/option-list/option-list.ts +518 -0
- package/dist/templates/components/password/index.ts +2 -0
- package/dist/templates/components/password/password-texts.ts +68 -0
- package/dist/templates/components/password/password.ts +363 -0
- package/dist/templates/components/popover/popover.ts +12 -4
- package/dist/templates/components/progress/index.ts +1 -0
- package/dist/templates/components/progress/progress.ts +217 -0
- package/dist/templates/components/select/select.ts +3 -2
- package/dist/templates/components/slider/index.ts +2 -0
- package/dist/templates/components/slider/slider-texts.ts +26 -0
- package/dist/templates/components/slider/slider.ts +425 -0
- package/dist/templates/components/tabs/tabs.ts +88 -23
- package/dist/templates/components/tags-input/index.ts +2 -0
- package/dist/templates/components/tags-input/tags-input-texts.ts +44 -0
- package/dist/templates/components/tags-input/tags-input.ts +303 -0
- package/dist/templates/components/toggle-group/index.ts +1 -0
- package/dist/templates/components/toggle-group/toggle-group.ts +406 -0
- package/dist/templates/theme/provider.ts +13 -0
- package/dist/templates/theme/theme-base.css +103 -0
- package/dist/templates/theme/types.ts +216 -0
- package/dist/templates/utils/overlay.ts +17 -4
- package/dist/ui/index.js +232 -10
- package/dist/ui/schema.json +82 -2
- package/dist/utils/project.js +35 -0
- package/dist/utils/tailwind.js +80 -0
- package/dist/utils/write-files.js +49 -9
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './input-mask';
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
Directive,
|
|
5
|
+
ElementRef,
|
|
6
|
+
booleanAttribute,
|
|
7
|
+
computed,
|
|
8
|
+
inject,
|
|
9
|
+
input,
|
|
10
|
+
model,
|
|
11
|
+
output,
|
|
12
|
+
viewChild,
|
|
13
|
+
} from '@angular/core';
|
|
14
|
+
import { type FormValueControl, transformedValue } from '@angular/forms/signals';
|
|
15
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
16
|
+
import {
|
|
17
|
+
MIMI_FIELD_CONTROL,
|
|
18
|
+
injectFieldState,
|
|
19
|
+
type MimiFieldControl,
|
|
20
|
+
} from '@/components/ui/utils/field-state';
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* Input Mask (docs/design/Mimi F3 Slider OTP Numero Mascara.dc.html, «7 · Input Mask»; prefijo
|
|
24
|
+
* de tokens `mask`, spec 13). Control propio con `FormValueControl<string>`: el modelo guarda
|
|
25
|
+
* **solo los dígitos** («5512345678») y el campo muestra la máscara («(55) 1234 5678»), como el
|
|
26
|
+
* diseño (decidido por defecto en la G2.4, opción (b)). Usa `transformedValue()` de Signal Forms.
|
|
27
|
+
*
|
|
28
|
+
* Sintaxis: `0` es un dígito; cualquier otro carácter es un literal.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Cuántos dígitos caben en la máscara. */
|
|
32
|
+
export function mimiMaskLength(mask: string): number {
|
|
33
|
+
return [...mask].filter((c) => c === '0').length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Aplica la máscara a unos dígitos. Un literal se escribe solo si después viene un dígito
|
|
38
|
+
* («(5», no «(5) »): así borrar hacia atrás no se traba en los literales.
|
|
39
|
+
*/
|
|
40
|
+
export function mimiApplyMask(mask: string, digits: string): string {
|
|
41
|
+
let out = '';
|
|
42
|
+
let i = 0;
|
|
43
|
+
for (const slot of mask) {
|
|
44
|
+
if (i >= digits.length) break;
|
|
45
|
+
if (slot === '0') out += digits[i++];
|
|
46
|
+
else out += slot;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Los dígitos de un texto (con o sin literales), hasta lo que cabe en la máscara. */
|
|
52
|
+
export function mimiUnmask(mask: string, text: string): string {
|
|
53
|
+
return (text.match(/\d/g) ?? []).join('').slice(0, mimiMaskLength(mask));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Posición en el texto con máscara justo después del dígito número `count` (0: al principio). */
|
|
57
|
+
function caretAfterDigits(masked: string, count: number): number {
|
|
58
|
+
if (count <= 0) return 0;
|
|
59
|
+
let seen = 0;
|
|
60
|
+
for (let i = 0; i < masked.length; i++) {
|
|
61
|
+
if (/\d/.test(masked[i]) && ++seen === count) return i + 1;
|
|
62
|
+
}
|
|
63
|
+
return masked.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* La caja (como Input: alto, radio, borde, hover, foco y error) con el input sin su propia caja
|
|
68
|
+
* adentro, para poder poner el prefijo y los íconos al lado (G2.4b).
|
|
69
|
+
*/
|
|
70
|
+
const BOX_CLASSES = cn(
|
|
71
|
+
'relative flex w-full min-w-0 cursor-text items-center gap-[var(--mimi-mask-gap,8px)] px-3 font-sans text-foreground mimi-transition',
|
|
72
|
+
'h-[var(--mimi-input-height,var(--mimi-control-height,2.5rem))] rounded-[var(--mimi-input-radius,var(--mimi-radius,0.75rem))]',
|
|
73
|
+
'border border-input bg-input-background',
|
|
74
|
+
'not-data-[disabled]:hover:border-input-hover',
|
|
75
|
+
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring-soft',
|
|
76
|
+
'data-[invalid]:border-destructive data-[invalid]:focus-within:border-destructive data-[invalid]:focus-within:ring-destructive-ring',
|
|
77
|
+
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-[var(--mimi-disabled-opacity,0.5)]',
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** El texto: 14px, cifras tabulares y un poco de espacio, como el diseño (el marcador igual). */
|
|
81
|
+
const TEXT = 'text-sm tabular-nums tracking-[.02em] whitespace-pre';
|
|
82
|
+
const INPUT_CLASSES = cn(
|
|
83
|
+
'relative h-full w-full min-w-0 border-0 bg-transparent p-0 text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',
|
|
84
|
+
TEXT,
|
|
85
|
+
);
|
|
86
|
+
const GHOST_CLASSES = cn('pointer-events-none absolute inset-0 flex items-center', TEXT);
|
|
87
|
+
|
|
88
|
+
/** Prefijo fijo dentro de la caja (`+52`): un chip `muted`. */
|
|
89
|
+
@Directive({
|
|
90
|
+
selector: '[mimiMaskPrefix]',
|
|
91
|
+
host: { 'data-slot': 'prefix', '[class]': 'classes()' },
|
|
92
|
+
})
|
|
93
|
+
export class MimiMaskPrefix {
|
|
94
|
+
readonly userClass = input('', { alias: 'class' });
|
|
95
|
+
protected readonly classes = computed(() =>
|
|
96
|
+
cn(
|
|
97
|
+
'inline-flex shrink-0 items-center gap-1.5 -ms-1.5 px-2 text-[13px] font-semibold text-foreground',
|
|
98
|
+
'h-[var(--mimi-mask-prefix-height,26px)] rounded-[var(--mimi-mask-prefix-radius,var(--mimi-radius-sm))] bg-[color:var(--mimi-mask-prefix-bg,var(--mimi-muted))]',
|
|
99
|
+
this.userClass(),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Algo al principio de la caja (un ícono): `muted-foreground`, íconos de 16px. */
|
|
105
|
+
@Directive({
|
|
106
|
+
selector: '[mimiMaskStart]',
|
|
107
|
+
host: { 'data-slot': 'start', '[class]': 'classes()' },
|
|
108
|
+
})
|
|
109
|
+
export class MimiMaskStart {
|
|
110
|
+
readonly userClass = input('', { alias: 'class' });
|
|
111
|
+
protected readonly classes = computed(() =>
|
|
112
|
+
cn('inline-flex shrink-0 items-center text-muted-foreground [&_svg]:size-4', this.userClass()),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Algo al final de la caja (la marca de la tarjeta, un ícono de válido). */
|
|
117
|
+
@Directive({
|
|
118
|
+
selector: '[mimiMaskEnd]',
|
|
119
|
+
host: { 'data-slot': 'end', '[class]': 'classes()' },
|
|
120
|
+
})
|
|
121
|
+
export class MimiMaskEnd {
|
|
122
|
+
readonly userClass = input('', { alias: 'class' });
|
|
123
|
+
protected readonly classes = computed(() =>
|
|
124
|
+
cn('inline-flex shrink-0 items-center [&_svg]:size-4', this.userClass()),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Input Mask:
|
|
130
|
+
*
|
|
131
|
+
* ```html
|
|
132
|
+
* <mimi-input-mask mask="(00) 0000 0000" autocomplete="tel-national" aria-label="Teléfono"
|
|
133
|
+
* formControlName="phone" /> <!-- el valor: '5512345678' -->
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* Implementa `FormValueControl<string>`: funciona con `[formField]`, `formControlName` y
|
|
137
|
+
* `[(ngModel)]`. Con Signal Forms, una máscara incompleta deja el campo inválido (error `mask`);
|
|
138
|
+
* con Reactive Forms y `ngModel`, agrega un validador (por ejemplo, `Validators.minLength`).
|
|
139
|
+
*/
|
|
140
|
+
@Component({
|
|
141
|
+
selector: 'mimi-input-mask',
|
|
142
|
+
template: `
|
|
143
|
+
<ng-content select="[mimiMaskPrefix], [mimiMaskStart]" />
|
|
144
|
+
<span class="relative flex h-full min-w-0 flex-1 items-center">
|
|
145
|
+
@if (ghostRest()) {
|
|
146
|
+
<!-- El marcador de lo que falta: debajo del texto, con lo escrito transparente. -->
|
|
147
|
+
<span aria-hidden="true" data-slot="ghost" [class]="ghostClasses"
|
|
148
|
+
><span class="text-transparent">{{ raw() }}</span
|
|
149
|
+
><span
|
|
150
|
+
class="text-[color:var(--mimi-mask-placeholder,var(--mimi-input-placeholder,var(--mimi-muted-foreground)))]"
|
|
151
|
+
>{{ ghostRest() }}</span
|
|
152
|
+
></span
|
|
153
|
+
>
|
|
154
|
+
}
|
|
155
|
+
<input
|
|
156
|
+
#field
|
|
157
|
+
type="text"
|
|
158
|
+
[class]="inputClasses"
|
|
159
|
+
[id]="controlId()"
|
|
160
|
+
[value]="raw()"
|
|
161
|
+
[disabled]="disabled()"
|
|
162
|
+
[readOnly]="readonly()"
|
|
163
|
+
[attr.inputmode]="inputmode()"
|
|
164
|
+
[attr.autocomplete]="autocomplete() || null"
|
|
165
|
+
[attr.placeholder]="placeholder() || null"
|
|
166
|
+
[attr.maxlength]="mask().length"
|
|
167
|
+
[attr.aria-label]="ariaLabel() || null"
|
|
168
|
+
[attr.aria-labelledby]="ariaLabelledby() || null"
|
|
169
|
+
[attr.aria-describedby]="ariaDescribedby() || null"
|
|
170
|
+
[attr.aria-invalid]="showInvalid() || null"
|
|
171
|
+
[attr.aria-required]="required() || null"
|
|
172
|
+
(input)="onInput()"
|
|
173
|
+
(keydown)="onKeydown($event)"
|
|
174
|
+
(compositionstart)="composing = true"
|
|
175
|
+
(compositionend)="composing = false; onInput()"
|
|
176
|
+
(blur)="touch.emit()"
|
|
177
|
+
/>
|
|
178
|
+
</span>
|
|
179
|
+
<ng-content select="[mimiMaskEnd]" />
|
|
180
|
+
`,
|
|
181
|
+
host: {
|
|
182
|
+
'[class]': 'boxClasses()',
|
|
183
|
+
'[attr.data-state]': 'complete() ? "complete" : digits() ? "partial" : "empty"',
|
|
184
|
+
'[attr.data-invalid]': 'showInvalid() ? "" : null',
|
|
185
|
+
'[attr.data-disabled]': 'disabled() ? "" : null',
|
|
186
|
+
'(click)': 'focusField($event)',
|
|
187
|
+
// El nombre y el id van al input de dentro.
|
|
188
|
+
'[attr.aria-label]': 'null',
|
|
189
|
+
'[attr.aria-labelledby]': 'null',
|
|
190
|
+
'[attr.aria-describedby]': 'null',
|
|
191
|
+
'[attr.id]': 'null',
|
|
192
|
+
},
|
|
193
|
+
providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiInputMask }],
|
|
194
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
195
|
+
})
|
|
196
|
+
export class MimiInputMask implements FormValueControl<string>, MimiFieldControl {
|
|
197
|
+
/** Solo los dígitos. Con `[(value)]` o un formulario. */
|
|
198
|
+
readonly value = model('');
|
|
199
|
+
/** La máscara: `0` es un dígito; lo demás, literales. Por ejemplo, `(00) 0000 0000`. */
|
|
200
|
+
readonly mask = input.required<string>();
|
|
201
|
+
/** `numeric` por defecto (teclado de números en el móvil). */
|
|
202
|
+
readonly inputmode = input('numeric');
|
|
203
|
+
readonly autocomplete = input<string>();
|
|
204
|
+
readonly placeholder = input('');
|
|
205
|
+
/**
|
|
206
|
+
* Marcador de lo que falta, del largo de la máscara (por ejemplo, `DD/MM/AAAA`): se ve debajo
|
|
207
|
+
* de lo escrito, en el color del placeholder. Decorativo (`aria-hidden`): di el formato con
|
|
208
|
+
* texto en `aria-describedby`.
|
|
209
|
+
*/
|
|
210
|
+
readonly ghost = input('');
|
|
211
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
212
|
+
readonly readonly = input(false, { transform: booleanAttribute });
|
|
213
|
+
/** Los pasa el formulario. Se muestra el error si es inválido y se tocó o modificó. */
|
|
214
|
+
readonly invalid = input(false, { transform: booleanAttribute });
|
|
215
|
+
readonly touched = input(false, { transform: booleanAttribute });
|
|
216
|
+
readonly dirty = input(false, { transform: booleanAttribute });
|
|
217
|
+
readonly required = input(false, { transform: booleanAttribute });
|
|
218
|
+
/** id del input, para un `<label for>`. */
|
|
219
|
+
readonly id = input<string>();
|
|
220
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
221
|
+
readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
|
|
222
|
+
readonly ariaDescribedby = input<string>(undefined, { alias: 'aria-describedby' });
|
|
223
|
+
/** Clases de la caja, mezcladas con `cn()` (gana la del usuario). */
|
|
224
|
+
readonly userClass = input('', { alias: 'class' });
|
|
225
|
+
|
|
226
|
+
/** Se emite al salir del campo: marca el control como tocado. */
|
|
227
|
+
readonly touch = output<void>();
|
|
228
|
+
|
|
229
|
+
private readonly field = viewChild.required<ElementRef<HTMLInputElement>>('field');
|
|
230
|
+
|
|
231
|
+
/** Estado del formulario, para mimi-form-field. */
|
|
232
|
+
readonly fieldState = injectFieldState();
|
|
233
|
+
get controlElement(): HTMLElement {
|
|
234
|
+
return this.field().nativeElement;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private static nextId = 0;
|
|
238
|
+
private readonly baseId = `mimi-input-mask-${MimiInputMask.nextId++}`;
|
|
239
|
+
protected readonly controlId = computed(() => this.id() || this.baseId);
|
|
240
|
+
readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
|
|
241
|
+
|
|
242
|
+
/** Lo que se pinta: el valor con la máscara. Con Signal Forms, una máscara incompleta es un error. */
|
|
243
|
+
protected readonly raw = transformedValue(this.value, {
|
|
244
|
+
parse: (text: string) => {
|
|
245
|
+
const digits = mimiUnmask(this.mask(), text);
|
|
246
|
+
const incomplete = digits.length > 0 && digits.length < mimiMaskLength(this.mask());
|
|
247
|
+
return incomplete ? { value: digits, error: { kind: 'mask' } } : { value: digits };
|
|
248
|
+
},
|
|
249
|
+
format: (digits: string) => mimiApplyMask(this.mask(), mimiUnmask(this.mask(), digits ?? '')),
|
|
250
|
+
});
|
|
251
|
+
protected readonly digits = computed(() => mimiUnmask(this.mask(), this.value() ?? ''));
|
|
252
|
+
protected readonly complete = computed(
|
|
253
|
+
() => this.digits().length === mimiMaskLength(this.mask()),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
/** Lo que falta del marcador: lo que está después de lo escrito. */
|
|
257
|
+
protected readonly ghostRest = computed(() => {
|
|
258
|
+
const ghost = this.ghost();
|
|
259
|
+
return ghost ? ghost.slice(this.raw().length) : '';
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
protected readonly boxClasses = computed(() => cn(BOX_CLASSES, this.userClass()));
|
|
263
|
+
protected readonly inputClasses = INPUT_CLASSES;
|
|
264
|
+
protected readonly ghostClasses = GHOST_CLASSES;
|
|
265
|
+
|
|
266
|
+
/** Un clic en la caja (el prefijo, el ícono o el relleno) lleva el foco al campo. */
|
|
267
|
+
protected focusField(event: MouseEvent): void {
|
|
268
|
+
const el = this.field().nativeElement;
|
|
269
|
+
if (event.target !== el && !el.disabled) el.focus();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Durante la composición de texto (IME) no se reformatea. */
|
|
273
|
+
protected composing = false;
|
|
274
|
+
|
|
275
|
+
/** Reformatea lo escrito o pegado y repone el cursor después del mismo dígito. */
|
|
276
|
+
protected onInput(): void {
|
|
277
|
+
if (this.composing) return;
|
|
278
|
+
const el = this.field().nativeElement;
|
|
279
|
+
const caret = el.selectionStart ?? el.value.length;
|
|
280
|
+
const digitsBefore = (el.value.slice(0, caret).match(/\d/g) ?? []).length;
|
|
281
|
+
this.write(mimiUnmask(this.mask(), el.value), digitsBefore);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Borrar sobre un literal borra el dígito de al lado (si no, el literal vuelve y se traba). */
|
|
285
|
+
protected onKeydown(event: KeyboardEvent): void {
|
|
286
|
+
if (event.key !== 'Backspace' && event.key !== 'Delete') return;
|
|
287
|
+
const el = this.field().nativeElement;
|
|
288
|
+
const start = el.selectionStart ?? 0;
|
|
289
|
+
if (start !== el.selectionEnd || this.readonly()) return;
|
|
290
|
+
const text = el.value;
|
|
291
|
+
const back = event.key === 'Backspace';
|
|
292
|
+
const neighbor = back ? text[start - 1] : text[start];
|
|
293
|
+
if (neighbor === undefined || /\d/.test(neighbor)) return;
|
|
294
|
+
// Hay un literal pegado al cursor: se salta y se borra el dígito siguiente en esa dirección.
|
|
295
|
+
const before = (text.slice(0, start).match(/\d/g) ?? []).length;
|
|
296
|
+
const digits = mimiUnmask(this.mask(), text);
|
|
297
|
+
const index = back ? before - 1 : before;
|
|
298
|
+
if (index < 0 || index >= digits.length) return;
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
this.write(digits.slice(0, index) + digits.slice(index + 1), back ? before - 1 : before);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private write(digits: string, digitsBefore: number): void {
|
|
304
|
+
const el = this.field().nativeElement;
|
|
305
|
+
const masked = mimiApplyMask(this.mask(), digits);
|
|
306
|
+
this.raw.set(masked);
|
|
307
|
+
if (el.value !== masked) el.value = masked;
|
|
308
|
+
const caret = caretAfterDigits(masked, Math.min(digitsBefore, digits.length));
|
|
309
|
+
if (el.ownerDocument.activeElement === el) el.setSelectionRange(caret, caret);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Todas las piezas de Input Mask, para importarlas juntas. */
|
|
314
|
+
export const MimiInputMaskImports = [
|
|
315
|
+
MimiInputMask,
|
|
316
|
+
MimiMaskPrefix,
|
|
317
|
+
MimiMaskStart,
|
|
318
|
+
MimiMaskEnd,
|
|
319
|
+
] as const;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './input-otp';
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
ElementRef,
|
|
5
|
+
booleanAttribute,
|
|
6
|
+
computed,
|
|
7
|
+
input,
|
|
8
|
+
model,
|
|
9
|
+
numberAttribute,
|
|
10
|
+
output,
|
|
11
|
+
signal,
|
|
12
|
+
viewChild,
|
|
13
|
+
} from '@angular/core';
|
|
14
|
+
import type { FormValueControl } from '@angular/forms/signals';
|
|
15
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
16
|
+
import {
|
|
17
|
+
MIMI_FIELD_CONTROL,
|
|
18
|
+
injectFieldState,
|
|
19
|
+
type MimiFieldControl,
|
|
20
|
+
} from '@/components/ui/utils/field-state';
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* Input OTP (docs/design/Mimi F3 Slider OTP Numero Mascara.dc.html, «5 · Input OTP»; prefijo de
|
|
24
|
+
* tokens `otp`, spec 13). **Un solo `<input>` real**, transparente y encima de las casillas
|
|
25
|
+
* (decidido por defecto en la G2.5, como el diseño): el lector lee un campo con el código entero,
|
|
26
|
+
* y pegar, borrar y el autocompletado del SMS (`autocomplete="one-time-code"`) son los nativos.
|
|
27
|
+
* Las casillas son `aria-hidden`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const BOX_BASE =
|
|
31
|
+
'relative flex shrink-0 items-center justify-center border font-semibold tabular-nums text-foreground mimi-transition w-[var(--mimi-otp-width,46px)] h-[var(--mimi-otp-height,54px)] rounded-[var(--mimi-otp-radius,var(--mimi-radius,0.75rem))] text-[length:var(--mimi-otp-font-size,22px)] bg-[color:var(--mimi-otp-bg,var(--mimi-input-background))]';
|
|
32
|
+
/** Borde y anillo de cada casilla según el estado (elegidos en TypeScript: menos CSS global). */
|
|
33
|
+
const BOX_STATE = {
|
|
34
|
+
idle: 'border-[color:var(--mimi-otp-border,var(--mimi-input))]',
|
|
35
|
+
active: 'border-ring ring-3 ring-ring-soft',
|
|
36
|
+
success: 'border-success ring-3 ring-success-ring',
|
|
37
|
+
error: 'border-destructive ring-3 ring-destructive-ring',
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Input OTP:
|
|
42
|
+
*
|
|
43
|
+
* ```html
|
|
44
|
+
* <mimi-input-otp aria-label="Código de verificación" [length]="6" [separatorAfter]="3"
|
|
45
|
+
* formControlName="code" />
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* Implementa `FormValueControl<string>`: funciona con `[formField]`, `formControlName` y
|
|
49
|
+
* `[(ngModel)]`. El valor son los caracteres escritos, sin espacios ni guiones.
|
|
50
|
+
*/
|
|
51
|
+
@Component({
|
|
52
|
+
selector: 'mimi-input-otp',
|
|
53
|
+
template: `
|
|
54
|
+
@for (box of boxes(); track $index) {
|
|
55
|
+
@if (box.separator) {
|
|
56
|
+
<span
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
data-slot="separator"
|
|
59
|
+
class="h-0.5 w-3 shrink-0 rounded-[2px] bg-[color:var(--mimi-otp-separator,var(--mimi-border))]"
|
|
60
|
+
></span>
|
|
61
|
+
}
|
|
62
|
+
<span
|
|
63
|
+
aria-hidden="true"
|
|
64
|
+
data-slot="box"
|
|
65
|
+
[class]="box.classes"
|
|
66
|
+
[attr.data-active]="box.active ? '' : null"
|
|
67
|
+
>
|
|
68
|
+
{{ box.char }}
|
|
69
|
+
@if (box.caret) {
|
|
70
|
+
<span
|
|
71
|
+
data-slot="caret"
|
|
72
|
+
class="h-[22px] w-0.5 rounded-[1px] bg-[color:var(--mimi-otp-caret,var(--mimi-foreground))] animate-[mimi-caret_1s_steps(1)_infinite] motion-reduce:animate-none"
|
|
73
|
+
></span>
|
|
74
|
+
}
|
|
75
|
+
</span>
|
|
76
|
+
}
|
|
77
|
+
<input
|
|
78
|
+
#field
|
|
79
|
+
type="text"
|
|
80
|
+
class="absolute inset-0 h-full w-full cursor-text border-0 bg-transparent p-0 text-base text-transparent caret-transparent opacity-0 outline-none disabled:cursor-not-allowed"
|
|
81
|
+
[id]="controlId()"
|
|
82
|
+
[value]="value() ?? ''"
|
|
83
|
+
[disabled]="disabled()"
|
|
84
|
+
[readOnly]="readonly()"
|
|
85
|
+
[attr.maxlength]="safeLength()"
|
|
86
|
+
[attr.inputmode]="alphanumeric() ? 'text' : 'numeric'"
|
|
87
|
+
[attr.autocomplete]="autocomplete()"
|
|
88
|
+
[attr.autocapitalize]="alphanumeric() ? 'characters' : null"
|
|
89
|
+
[attr.spellcheck]="false"
|
|
90
|
+
[attr.aria-label]="ariaLabel() || null"
|
|
91
|
+
[attr.aria-labelledby]="ariaLabelledby() || null"
|
|
92
|
+
[attr.aria-describedby]="ariaDescribedby() || null"
|
|
93
|
+
[attr.aria-invalid]="showInvalid() || null"
|
|
94
|
+
[attr.aria-required]="required() || null"
|
|
95
|
+
(input)="onInput()"
|
|
96
|
+
(focus)="focused.set(true)"
|
|
97
|
+
(blur)="focused.set(false); touch.emit()"
|
|
98
|
+
/>
|
|
99
|
+
`,
|
|
100
|
+
host: {
|
|
101
|
+
'[class]': 'classes()',
|
|
102
|
+
'[attr.data-state]': 'state()',
|
|
103
|
+
'[attr.data-invalid]': 'showInvalid() ? "" : null',
|
|
104
|
+
'[attr.data-success]': 'success() ? "" : null',
|
|
105
|
+
'[attr.data-disabled]': 'disabled() ? "" : null',
|
|
106
|
+
'[attr.aria-label]': 'null',
|
|
107
|
+
'[attr.aria-labelledby]': 'null',
|
|
108
|
+
'[attr.aria-describedby]': 'null',
|
|
109
|
+
'[attr.id]': 'null',
|
|
110
|
+
},
|
|
111
|
+
providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiInputOtp }],
|
|
112
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
113
|
+
})
|
|
114
|
+
export class MimiInputOtp implements FormValueControl<string>, MimiFieldControl {
|
|
115
|
+
/** El código: solo dígitos (o letras y dígitos con `alphanumeric`). */
|
|
116
|
+
readonly value = model('');
|
|
117
|
+
/** Cuántas casillas (6 en el diseño). */
|
|
118
|
+
readonly length = input(6, { transform: numberAttribute });
|
|
119
|
+
/** Después de qué casilla va el guion (3 en el diseño). Sin él, no hay guion. */
|
|
120
|
+
readonly separatorAfter = input<number | undefined, unknown>(undefined, {
|
|
121
|
+
transform: (v: unknown) =>
|
|
122
|
+
v === null || v === undefined || v === '' ? undefined : numberAttribute(v),
|
|
123
|
+
});
|
|
124
|
+
/** Letras y dígitos (algunos códigos los tienen). Por defecto, solo dígitos. */
|
|
125
|
+
readonly alphanumeric = input(false, { transform: booleanAttribute });
|
|
126
|
+
/** El código se verificó: borde y anillo `success` (como «Código verificado.» del diseño). */
|
|
127
|
+
readonly success = input(false, { transform: booleanAttribute });
|
|
128
|
+
/** `one-time-code` por defecto: el sistema propone el código del SMS. */
|
|
129
|
+
readonly autocomplete = input('one-time-code');
|
|
130
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
131
|
+
readonly readonly = input(false, { transform: booleanAttribute });
|
|
132
|
+
/** Los pasa el formulario. Se muestra el error si es inválido y se tocó o modificó. */
|
|
133
|
+
readonly invalid = input(false, { transform: booleanAttribute });
|
|
134
|
+
readonly touched = input(false, { transform: booleanAttribute });
|
|
135
|
+
readonly dirty = input(false, { transform: booleanAttribute });
|
|
136
|
+
readonly required = input(false, { transform: booleanAttribute });
|
|
137
|
+
/** id del input, para un `<label for>`. */
|
|
138
|
+
readonly id = input<string>();
|
|
139
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
140
|
+
readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
|
|
141
|
+
readonly ariaDescribedby = input<string>(undefined, { alias: 'aria-describedby' });
|
|
142
|
+
/** Clases de la fila de casillas, mezcladas con `cn()` (gana la del usuario). */
|
|
143
|
+
readonly userClass = input('', { alias: 'class' });
|
|
144
|
+
|
|
145
|
+
/** Se emite al salir del campo: marca el control como tocado. */
|
|
146
|
+
readonly touch = output<void>();
|
|
147
|
+
|
|
148
|
+
private readonly field = viewChild.required<ElementRef<HTMLInputElement>>('field');
|
|
149
|
+
|
|
150
|
+
readonly fieldState = injectFieldState();
|
|
151
|
+
get controlElement(): HTMLElement {
|
|
152
|
+
return this.field().nativeElement;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private static nextId = 0;
|
|
156
|
+
private readonly baseId = `mimi-input-otp-${MimiInputOtp.nextId++}`;
|
|
157
|
+
protected readonly controlId = computed(() => this.id() || this.baseId);
|
|
158
|
+
readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
|
|
159
|
+
protected readonly focused = signal(false);
|
|
160
|
+
|
|
161
|
+
protected readonly safeLength = computed(() => {
|
|
162
|
+
const n = Math.floor(this.length());
|
|
163
|
+
return Number.isFinite(n) && n > 0 ? n : 6;
|
|
164
|
+
});
|
|
165
|
+
private readonly chars = computed(() => this.clean(this.value() ?? ''));
|
|
166
|
+
protected readonly state = computed(() => {
|
|
167
|
+
const n = this.chars().length;
|
|
168
|
+
return n === 0 ? 'empty' : n >= this.safeLength() ? 'complete' : 'partial';
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
protected readonly boxes = computed(() => {
|
|
172
|
+
const chars = this.chars();
|
|
173
|
+
const length = this.safeLength();
|
|
174
|
+
const after = this.separatorAfter();
|
|
175
|
+
// La casilla activa: la siguiente a llenar (la última si está completo), con el foco.
|
|
176
|
+
const activeIndex = Math.min(chars.length, length - 1);
|
|
177
|
+
const status = this.showInvalid() ? 'error' : this.success() ? 'success' : null;
|
|
178
|
+
return Array.from({ length }, (_, i) => {
|
|
179
|
+
const active = this.focused() && !this.disabled() && i === activeIndex && !status;
|
|
180
|
+
return {
|
|
181
|
+
char: chars[i] ?? '',
|
|
182
|
+
separator: after !== undefined && after > 0 && after < length && i === after,
|
|
183
|
+
active,
|
|
184
|
+
caret: active && !chars[i],
|
|
185
|
+
classes: cn(BOX_BASE, BOX_STATE[status ?? (active ? 'active' : 'idle')]),
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
protected readonly classes = computed(() =>
|
|
191
|
+
cn(
|
|
192
|
+
'relative inline-flex items-center gap-[var(--mimi-otp-gap,8px)] align-middle font-sans',
|
|
193
|
+
'data-[disabled]:opacity-[var(--mimi-disabled-opacity,0.5)]',
|
|
194
|
+
// Error: la fila se sacude una vez (sin movimiento con «reducir movimiento»).
|
|
195
|
+
'data-[invalid]:animate-[mimi-shake_.4s] motion-reduce:data-[invalid]:animate-none',
|
|
196
|
+
this.userClass(),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
/** Solo los caracteres válidos, hasta la longitud (quita espacios y guiones de lo pegado). */
|
|
201
|
+
private clean(text: string): string {
|
|
202
|
+
const pattern = this.alphanumeric() ? /[^\p{L}\p{N}]/gu : /\D/g;
|
|
203
|
+
return text.replace(pattern, '').slice(0, this.safeLength());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
protected onInput(): void {
|
|
207
|
+
const el = this.field().nativeElement;
|
|
208
|
+
const clean = this.clean(el.value);
|
|
209
|
+
if (el.value !== clean) el.value = clean;
|
|
210
|
+
if (clean !== this.value()) this.value.set(clean);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { InjectionToken, type Provider } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Textos de Number Input, en archivo propio: así se pueden proveer en `app.config.ts` sin llevar
|
|
5
|
+
* el componente al chunk inicial (como Select, G1.5).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Nombres accesibles de los botones. `label` es el nombre del campo (puede venir vacío). */
|
|
9
|
+
export interface MimiNumberInputText {
|
|
10
|
+
decrement: (label: string) => string;
|
|
11
|
+
increment: (label: string) => string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Textos predeterminados (inglés). */
|
|
15
|
+
export const MIMI_NUMBER_INPUT_TEXT_EN: MimiNumberInputText = {
|
|
16
|
+
decrement: (label) => (label ? `Decrease ${label}` : 'Decrease'),
|
|
17
|
+
increment: (label) => (label ? `Increase ${label}` : 'Increase'),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Textos en español: `provideMimiNumberInputText(MIMI_NUMBER_INPUT_TEXT_ES)`. */
|
|
21
|
+
export const MIMI_NUMBER_INPUT_TEXT_ES: MimiNumberInputText = {
|
|
22
|
+
decrement: (label) => (label ? `Disminuir ${label}` : 'Disminuir'),
|
|
23
|
+
increment: (label) => (label ? `Aumentar ${label}` : 'Aumentar'),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Textos activos. Sin `provideMimiNumberInputText`, los predeterminados en inglés. */
|
|
27
|
+
export const MIMI_NUMBER_INPUT_TEXT = new InjectionToken<MimiNumberInputText>(
|
|
28
|
+
'MIMI_NUMBER_INPUT_TEXT',
|
|
29
|
+
{ providedIn: 'root', factory: () => MIMI_NUMBER_INPUT_TEXT_EN },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cambia los textos de Number Input. Lo que no definas sigue en inglés:
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* provideMimiNumberInputText(MIMI_NUMBER_INPUT_TEXT_ES)
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* Va en `app.config.ts` o en los `providers` de un componente o ruta.
|
|
40
|
+
*/
|
|
41
|
+
export function provideMimiNumberInputText(text: Partial<MimiNumberInputText>): Provider {
|
|
42
|
+
return {
|
|
43
|
+
provide: MIMI_NUMBER_INPUT_TEXT,
|
|
44
|
+
useValue: { ...MIMI_NUMBER_INPUT_TEXT_EN, ...text },
|
|
45
|
+
};
|
|
46
|
+
}
|