@mimi-ng/cli 0.1.1 → 0.3.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 +28 -1
- package/dist/init/css.js +17 -0
- package/dist/init/index.js +7 -5
- package/dist/init/items.js +9 -0
- package/dist/registry.json +482 -1
- package/dist/templates/components/avatar/avatar.ts +1 -1
- package/dist/templates/components/avatar/avatar.variants.ts +4 -4
- package/dist/templates/components/badge/badge.ts +41 -6
- package/dist/templates/components/badge/badge.variants.ts +105 -11
- package/dist/templates/components/button/button.ts +74 -7
- package/dist/templates/components/button/button.variants.ts +145 -21
- 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/card/card.ts +8 -5
- package/dist/templates/components/checkbox/checkbox.ts +3 -3
- 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/confirm/confirm-texts.ts +46 -0
- package/dist/templates/components/confirm/confirm.ts +190 -0
- package/dist/templates/components/confirm/index.ts +2 -0
- package/dist/templates/components/context-menu/context-menu.ts +378 -0
- package/dist/templates/components/context-menu/index.ts +14 -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/dialog/dialog-texts.ts +41 -0
- package/dist/templates/components/dialog/dialog.ts +701 -0
- package/dist/templates/components/dialog/index.ts +18 -0
- package/dist/templates/components/dropdown-menu/dropdown-menu.ts +252 -0
- package/dist/templates/components/dropdown-menu/index.ts +14 -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/form-field/form-error.ts +1 -1
- package/dist/templates/components/form-field/form-field.ts +3 -2
- package/dist/templates/components/input/input.variants.ts +6 -4
- 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/menu/index.ts +15 -0
- package/dist/templates/components/menu/menu.ts +656 -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/pagination/index.ts +2 -0
- package/dist/templates/components/pagination/pagination-page-size.ts +74 -0
- package/dist/templates/components/pagination/pagination-texts.ts +69 -0
- package/dist/templates/components/pagination/pagination.ts +443 -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/index.ts +12 -0
- package/dist/templates/components/popover/popover.ts +407 -0
- package/dist/templates/components/progress/index.ts +1 -0
- package/dist/templates/components/progress/progress.ts +217 -0
- package/dist/templates/components/select/index.ts +12 -0
- package/dist/templates/components/select/select-texts.ts +62 -0
- package/dist/templates/components/select/select.ts +1086 -0
- package/dist/templates/components/separator/separator.variants.ts +10 -7
- package/dist/templates/components/skeleton/skeleton.ts +4 -1
- 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/switch/switch.ts +6 -6
- package/dist/templates/components/tabs/index.ts +2 -0
- package/dist/templates/components/tabs/tabs.ts +426 -0
- 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/textarea/textarea.variants.ts +14 -11
- package/dist/templates/components/toast/index.ts +3 -0
- package/dist/templates/components/toast/toast-config.ts +45 -0
- package/dist/templates/components/toast/toast-texts.ts +46 -0
- package/dist/templates/components/toast/toast.ts +1000 -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/components/toolbar/index.ts +1 -0
- package/dist/templates/components/toolbar/toolbar.ts +377 -0
- package/dist/templates/components/tooltip/index.ts +7 -0
- package/dist/templates/components/tooltip/tooltip.ts +407 -0
- package/dist/templates/theme/provider.ts +75 -6
- package/dist/templates/theme/theme-base.css +410 -9
- package/dist/templates/theme/types.ts +494 -4
- package/dist/templates/utils/cn.ts +15 -1
- package/dist/templates/utils/control-styles.ts +2 -2
- package/dist/templates/utils/overlay.ts +153 -0
- package/dist/ui/index.js +184 -8
- package/dist/ui/schema.json +100 -0
- package/dist/utils/write-files.js +166 -59
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
DestroyRef,
|
|
5
|
+
ElementRef,
|
|
6
|
+
LOCALE_ID,
|
|
7
|
+
booleanAttribute,
|
|
8
|
+
computed,
|
|
9
|
+
inject,
|
|
10
|
+
input,
|
|
11
|
+
model,
|
|
12
|
+
output,
|
|
13
|
+
signal,
|
|
14
|
+
viewChild,
|
|
15
|
+
} from '@angular/core';
|
|
16
|
+
import type { FormValueControl } from '@angular/forms/signals';
|
|
17
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
18
|
+
import {
|
|
19
|
+
MIMI_FIELD_CONTROL,
|
|
20
|
+
injectFieldState,
|
|
21
|
+
type MimiFieldControl,
|
|
22
|
+
} from '@/components/ui/utils/field-state';
|
|
23
|
+
import { MIMI_NUMBER_INPUT_TEXT } from './number-input-texts';
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* Number Input (docs/design/Mimi F3 Slider OTP Numero Mascara.dc.html, «6 · Number Input»;
|
|
27
|
+
* prefijo de tokens `number`, spec 13). Patrón «Spinbutton» de la APG con un `<input>` de texto
|
|
28
|
+
* (`role="spinbutton"`, `inputmode="decimal"`): admite el texto formateado con
|
|
29
|
+
* `Intl.NumberFormat`, que un `type="number"` no permite.
|
|
30
|
+
*
|
|
31
|
+
* El texto se escribe libre y se interpreta al salir del campo (o con Enter), con los
|
|
32
|
+
* separadores del idioma; mientras se escribe no se reformatea (no se mueve el cursor).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const toNumber = (v: unknown): number | undefined => {
|
|
36
|
+
if (v === null || v === undefined || v === '') return undefined;
|
|
37
|
+
const n = Number(v);
|
|
38
|
+
return Number.isFinite(n) ? n : undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Decimales de un número (para redondear al paso sin errores de coma flotante). */
|
|
42
|
+
const decimals = (n: number): number => {
|
|
43
|
+
const text = String(n);
|
|
44
|
+
if (text.includes('e-')) return Number(text.split('e-')[1]);
|
|
45
|
+
return text.includes('.') ? text.split('.')[1].length : 0;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const BOX_CLASSES = cn(
|
|
49
|
+
'relative flex w-full min-w-0 items-center gap-0.5 font-sans text-foreground mimi-transition',
|
|
50
|
+
'h-[var(--mimi-number-height,var(--mimi-control-height,2.5rem))] rounded-[var(--mimi-number-radius,var(--mimi-radius,0.75rem))] px-[var(--mimi-number-px,4px)]',
|
|
51
|
+
'border border-input bg-input-background',
|
|
52
|
+
// Hover, foco, error y deshabilitado como Input; el foco está en el input de dentro.
|
|
53
|
+
'not-data-[disabled]:hover:border-input-hover',
|
|
54
|
+
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring-soft',
|
|
55
|
+
'data-[invalid]:border-destructive data-[invalid]:focus-within:border-destructive data-[invalid]:focus-within:ring-destructive-ring',
|
|
56
|
+
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-[var(--mimi-disabled-opacity,0.5)]',
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const INPUT_CLASSES =
|
|
60
|
+
'h-full min-w-0 flex-1 border-0 bg-transparent p-0 text-center text-sm font-medium tabular-nums text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed';
|
|
61
|
+
|
|
62
|
+
const BUTTON_CLASSES = cn(
|
|
63
|
+
'inline-flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-foreground mimi-transition',
|
|
64
|
+
'size-[var(--mimi-number-button-size,30px)] rounded-[var(--mimi-number-button-radius,var(--mimi-radius-sm))]',
|
|
65
|
+
'[&_svg]:size-[15px] [&_svg]:shrink-0',
|
|
66
|
+
'not-aria-disabled:hover:bg-[color:var(--mimi-number-button-hover,var(--mimi-accent))] not-aria-disabled:active:scale-(--mimi-press-scale-sm)',
|
|
67
|
+
'aria-disabled:cursor-not-allowed aria-disabled:opacity-[var(--mimi-disabled-opacity,0.5)]',
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/** Mantener pulsado − o +: primer repetición a los 400 ms, después cada 80 ms. */
|
|
71
|
+
const REPEAT_DELAY = 400;
|
|
72
|
+
const REPEAT_EVERY = 80;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Number Input:
|
|
76
|
+
*
|
|
77
|
+
* ```html
|
|
78
|
+
* <mimi-number-input aria-label="Cantidad" [(value)]="cantidad" [min]="0" [max]="10" />
|
|
79
|
+
* <mimi-number-input aria-label="Precio" [(value)]="precio" [step]="50"
|
|
80
|
+
* [format]="{ style: 'currency', currency: 'MXN' }" locale="es-MX" />
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* Implementa `FormValueControl<number | null>`: funciona con `[formField]`, `formControlName` y
|
|
84
|
+
* `[(ngModel)]`. Vacío es `null`. Un valor fuera de rango que llega de fuera se pinta acotado sin
|
|
85
|
+
* reescribir el modelo.
|
|
86
|
+
*/
|
|
87
|
+
@Component({
|
|
88
|
+
selector: 'mimi-number-input',
|
|
89
|
+
template: `
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
tabindex="-1"
|
|
93
|
+
data-slot="decrement"
|
|
94
|
+
[class]="buttonClasses"
|
|
95
|
+
[attr.aria-label]="text.decrement(labelText())"
|
|
96
|
+
[attr.aria-disabled]="!canDecrement() || null"
|
|
97
|
+
[attr.aria-controls]="controlId()"
|
|
98
|
+
(pointerdown)="press($event, -1)"
|
|
99
|
+
(click)="tap(-1)"
|
|
100
|
+
>
|
|
101
|
+
<svg
|
|
102
|
+
viewBox="0 0 24 24"
|
|
103
|
+
fill="none"
|
|
104
|
+
stroke="currentColor"
|
|
105
|
+
stroke-width="2"
|
|
106
|
+
stroke-linecap="round"
|
|
107
|
+
stroke-linejoin="round"
|
|
108
|
+
aria-hidden="true"
|
|
109
|
+
>
|
|
110
|
+
<!-- Lucide minus (ISC) -->
|
|
111
|
+
<path d="M5 12h14" />
|
|
112
|
+
</svg>
|
|
113
|
+
</button>
|
|
114
|
+
<input
|
|
115
|
+
#field
|
|
116
|
+
type="text"
|
|
117
|
+
role="spinbutton"
|
|
118
|
+
inputmode="decimal"
|
|
119
|
+
autocomplete="off"
|
|
120
|
+
[class]="inputClasses"
|
|
121
|
+
[id]="controlId()"
|
|
122
|
+
[value]="display()"
|
|
123
|
+
[disabled]="disabled()"
|
|
124
|
+
[readOnly]="readonly()"
|
|
125
|
+
[attr.placeholder]="placeholder() || null"
|
|
126
|
+
[attr.aria-label]="ariaLabel() || null"
|
|
127
|
+
[attr.aria-labelledby]="ariaLabelledby() || null"
|
|
128
|
+
[attr.aria-valuenow]="shown()"
|
|
129
|
+
[attr.aria-valuemin]="min() ?? null"
|
|
130
|
+
[attr.aria-valuemax]="max() ?? null"
|
|
131
|
+
[attr.aria-valuetext]="shown() === null ? null : formatted(shown()!)"
|
|
132
|
+
[attr.aria-invalid]="showInvalid() || null"
|
|
133
|
+
[attr.aria-required]="required() || null"
|
|
134
|
+
(focus)="startEditing()"
|
|
135
|
+
(input)="draft.set(field.value)"
|
|
136
|
+
(blur)="commit()"
|
|
137
|
+
(keydown)="onKeydown($event)"
|
|
138
|
+
/>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
tabindex="-1"
|
|
142
|
+
data-slot="increment"
|
|
143
|
+
[class]="buttonClasses"
|
|
144
|
+
[attr.aria-label]="text.increment(labelText())"
|
|
145
|
+
[attr.aria-disabled]="!canIncrement() || null"
|
|
146
|
+
[attr.aria-controls]="controlId()"
|
|
147
|
+
(pointerdown)="press($event, 1)"
|
|
148
|
+
(click)="tap(1)"
|
|
149
|
+
>
|
|
150
|
+
<svg
|
|
151
|
+
viewBox="0 0 24 24"
|
|
152
|
+
fill="none"
|
|
153
|
+
stroke="currentColor"
|
|
154
|
+
stroke-width="2"
|
|
155
|
+
stroke-linecap="round"
|
|
156
|
+
stroke-linejoin="round"
|
|
157
|
+
aria-hidden="true"
|
|
158
|
+
>
|
|
159
|
+
<!-- Lucide plus (ISC) -->
|
|
160
|
+
<path d="M5 12h14" />
|
|
161
|
+
<path d="M12 5v14" />
|
|
162
|
+
</svg>
|
|
163
|
+
</button>
|
|
164
|
+
`,
|
|
165
|
+
host: {
|
|
166
|
+
'[class]': 'classes()',
|
|
167
|
+
'[attr.data-state]': 'editing() ? "editing" : "idle"',
|
|
168
|
+
'[attr.data-invalid]': 'showInvalid() ? "" : null',
|
|
169
|
+
'[attr.data-disabled]': 'disabled() ? "" : null',
|
|
170
|
+
'[attr.data-readonly]': 'readonly() ? "" : null',
|
|
171
|
+
// El nombre va al input de dentro: en el anfitrión no lo lee nadie.
|
|
172
|
+
'[attr.aria-label]': 'null',
|
|
173
|
+
'[attr.aria-labelledby]': 'null',
|
|
174
|
+
'[attr.id]': 'null',
|
|
175
|
+
'(focusout)': 'onFocusOut($event)',
|
|
176
|
+
},
|
|
177
|
+
providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiNumberInput }],
|
|
178
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
179
|
+
})
|
|
180
|
+
export class MimiNumberInput implements FormValueControl<number | null>, MimiFieldControl {
|
|
181
|
+
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
|
182
|
+
private readonly appLocale = inject(LOCALE_ID);
|
|
183
|
+
protected readonly text = inject(MIMI_NUMBER_INPUT_TEXT);
|
|
184
|
+
|
|
185
|
+
/** El número, o `null` si está vacío. Con `[(value)]` o un formulario. */
|
|
186
|
+
readonly value = model<number | null>(null);
|
|
187
|
+
readonly min = input<number | undefined, unknown>(undefined, { transform: toNumber });
|
|
188
|
+
readonly max = input<number | undefined, unknown>(undefined, { transform: toNumber });
|
|
189
|
+
/** Paso de ↑/↓ y de los botones; al salir del campo, el valor se redondea a él. */
|
|
190
|
+
readonly step = input<number, unknown>(1, {
|
|
191
|
+
transform: (v: unknown) => {
|
|
192
|
+
const n = toNumber(v);
|
|
193
|
+
return n !== undefined && n > 0 ? n : 1;
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
/** Opciones de `Intl.NumberFormat` (moneda, decimales, unidad…). */
|
|
197
|
+
readonly format = input<Intl.NumberFormatOptions | undefined>(undefined);
|
|
198
|
+
/** Idioma del formato y de la lectura. Por defecto, el `LOCALE_ID` de Angular. */
|
|
199
|
+
readonly locale = input<string | undefined>(undefined);
|
|
200
|
+
readonly placeholder = input('');
|
|
201
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
202
|
+
readonly readonly = input(false, { transform: booleanAttribute });
|
|
203
|
+
/** Los pasa el formulario. Se muestra el error si es inválido y se tocó o modificó. */
|
|
204
|
+
readonly invalid = input(false, { transform: booleanAttribute });
|
|
205
|
+
readonly touched = input(false, { transform: booleanAttribute });
|
|
206
|
+
readonly dirty = input(false, { transform: booleanAttribute });
|
|
207
|
+
readonly required = input(false, { transform: booleanAttribute });
|
|
208
|
+
/** id del input, para una etiqueta externa (`<label for>`). */
|
|
209
|
+
readonly id = input<string>();
|
|
210
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
211
|
+
readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
|
|
212
|
+
/** Clases de la caja, mezcladas con `cn()` (gana la del usuario). */
|
|
213
|
+
readonly userClass = input('', { alias: 'class' });
|
|
214
|
+
|
|
215
|
+
/** Se emite cuando el foco sale de la caja entera: marca el control como tocado. */
|
|
216
|
+
readonly touch = output<void>();
|
|
217
|
+
|
|
218
|
+
private readonly field = viewChild.required<ElementRef<HTMLInputElement>>('field');
|
|
219
|
+
|
|
220
|
+
/** Estado del formulario, para el mensaje de mimi-form-field. */
|
|
221
|
+
readonly fieldState = injectFieldState();
|
|
222
|
+
/** El input: mimi-form-field le agrega el aria-describedby del mensaje. */
|
|
223
|
+
get controlElement(): HTMLElement {
|
|
224
|
+
return this.field().nativeElement;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private static nextId = 0;
|
|
228
|
+
private readonly baseId = `mimi-number-input-${MimiNumberInput.nextId++}`;
|
|
229
|
+
protected readonly controlId = computed(() => this.id() || this.baseId);
|
|
230
|
+
readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
|
|
231
|
+
|
|
232
|
+
/** Con el foco: el texto que se escribe (sin formato). */
|
|
233
|
+
protected readonly editing = signal(false);
|
|
234
|
+
protected readonly draft = signal('');
|
|
235
|
+
|
|
236
|
+
private readonly localeId = computed(() => this.locale() || this.appLocale);
|
|
237
|
+
private readonly formatter = computed(
|
|
238
|
+
() => new Intl.NumberFormat(this.localeId(), this.format()),
|
|
239
|
+
);
|
|
240
|
+
/** Para editar: el número con el decimal del idioma, sin separador de miles. */
|
|
241
|
+
private readonly plain = computed(
|
|
242
|
+
() => new Intl.NumberFormat(this.localeId(), { useGrouping: false, maximumFractionDigits: 20 }),
|
|
243
|
+
);
|
|
244
|
+
/** Separadores del idioma, para leer lo que se escribe. */
|
|
245
|
+
private readonly separators = computed(() => {
|
|
246
|
+
const parts = new Intl.NumberFormat(this.localeId()).formatToParts(-12345.6);
|
|
247
|
+
return {
|
|
248
|
+
group: parts.find((p) => p.type === 'group')?.value ?? ',',
|
|
249
|
+
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/** El valor que se pinta: acotado, sin tocar el modelo. */
|
|
254
|
+
protected readonly shown = computed(() => {
|
|
255
|
+
const value = this.value();
|
|
256
|
+
return value === null || !Number.isFinite(value) ? null : this.clamp(value);
|
|
257
|
+
});
|
|
258
|
+
protected readonly display = computed(() => {
|
|
259
|
+
if (this.editing()) return this.draft();
|
|
260
|
+
const shown = this.shown();
|
|
261
|
+
return shown === null ? '' : this.formatted(shown);
|
|
262
|
+
});
|
|
263
|
+
protected readonly canDecrement = computed(() => this.canStep(-1));
|
|
264
|
+
protected readonly canIncrement = computed(() => this.canStep(1));
|
|
265
|
+
/** Para «Disminuir {etiqueta}»: el aria-label o el texto de la etiqueta enlazada. */
|
|
266
|
+
protected readonly labelText = computed(() => {
|
|
267
|
+
const label = this.ariaLabel()?.trim();
|
|
268
|
+
if (label) return label;
|
|
269
|
+
const ids = this.ariaLabelledby()?.trim();
|
|
270
|
+
if (!ids) return '';
|
|
271
|
+
const doc = this.host.ownerDocument;
|
|
272
|
+
return ids
|
|
273
|
+
.split(/\s+/)
|
|
274
|
+
.map((id) => doc.getElementById(id)?.textContent?.trim() ?? '')
|
|
275
|
+
.join(' ')
|
|
276
|
+
.trim();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
protected readonly classes = computed(() => cn(BOX_CLASSES, this.userClass()));
|
|
280
|
+
protected readonly inputClasses = INPUT_CLASSES;
|
|
281
|
+
protected readonly buttonClasses = BUTTON_CLASSES;
|
|
282
|
+
|
|
283
|
+
private repeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
284
|
+
private pointerStepped = false;
|
|
285
|
+
|
|
286
|
+
constructor() {
|
|
287
|
+
inject(DestroyRef).onDestroy(() => this.stopRepeat());
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Uso interno: el número con el formato del usuario. */
|
|
291
|
+
formatted(value: number): string {
|
|
292
|
+
return this.formatter().format(value);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Lee un texto con los separadores del idioma («1.250,5» en es-ES → 1250.5). */
|
|
296
|
+
parse(text: string): number | null {
|
|
297
|
+
// Vacío es null; un texto sin cifras no es un número (se descarta al salir).
|
|
298
|
+
if (!text.trim()) return null;
|
|
299
|
+
if (!/\d/.test(text)) return NaN;
|
|
300
|
+
const { group, decimal } = this.separators();
|
|
301
|
+
let clean = text.replace(/\s/g, '').split(group).join('');
|
|
302
|
+
// Algunos idiomas usan un espacio fino o duro como separador de miles.
|
|
303
|
+
if (/\s/.test(group)) clean = clean.replace(/[ ]/g, '');
|
|
304
|
+
clean = clean.split(decimal).join('.').replace(/[−–]/g, '-');
|
|
305
|
+
clean = clean.replace(/[^\d.-]/g, '');
|
|
306
|
+
if (clean === '' || clean === '-' || clean === '.') return null;
|
|
307
|
+
const n = Number(clean);
|
|
308
|
+
return Number.isFinite(n) ? n : NaN;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
protected startEditing(): void {
|
|
312
|
+
if (this.editing()) return;
|
|
313
|
+
const shown = this.shown();
|
|
314
|
+
this.draft.set(shown === null ? '' : this.plain().format(shown));
|
|
315
|
+
this.editing.set(true);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Lee el texto, redondea al paso y acota. Un texto que no es un número se descarta. */
|
|
319
|
+
protected commit(): void {
|
|
320
|
+
if (!this.editing()) return;
|
|
321
|
+
const parsed = this.parse(this.draft());
|
|
322
|
+
this.editing.set(false);
|
|
323
|
+
if (this.readonly() || this.disabled()) return;
|
|
324
|
+
if (parsed === null) {
|
|
325
|
+
if (this.value() !== null) this.value.set(null);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (Number.isNaN(parsed)) return;
|
|
329
|
+
const next = this.clamp(this.roundToStep(parsed));
|
|
330
|
+
if (next !== this.value()) this.value.set(next);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
protected onKeydown(event: KeyboardEvent): void {
|
|
334
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
335
|
+
const step = this.step();
|
|
336
|
+
const actions: Record<string, () => void> = {
|
|
337
|
+
ArrowUp: () => this.stepBy(step),
|
|
338
|
+
ArrowDown: () => this.stepBy(-step),
|
|
339
|
+
PageUp: () => this.stepBy(step * 10),
|
|
340
|
+
PageDown: () => this.stepBy(-step * 10),
|
|
341
|
+
Home: () => this.min() !== undefined && this.setFromKeyboard(this.min()!),
|
|
342
|
+
End: () => this.max() !== undefined && this.setFromKeyboard(this.max()!),
|
|
343
|
+
Enter: () => {
|
|
344
|
+
this.commit();
|
|
345
|
+
this.startEditing();
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
const action = actions[event.key];
|
|
349
|
+
if (!action) return;
|
|
350
|
+
// Inicio y Fin sin límite son del campo de texto (mover el cursor).
|
|
351
|
+
if (
|
|
352
|
+
(event.key === 'Home' && this.min() === undefined) ||
|
|
353
|
+
(event.key === 'End' && this.max() === undefined)
|
|
354
|
+
) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
action();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Puntero en − o +: un paso ahora y, si se mantiene, repetir. El foco no se mueve. */
|
|
362
|
+
protected press(event: PointerEvent, direction: 1 | -1): void {
|
|
363
|
+
event.preventDefault();
|
|
364
|
+
if (event.button !== 0 || !this.canStep(direction)) return;
|
|
365
|
+
this.pointerStepped = true;
|
|
366
|
+
this.stepBy(direction * this.step());
|
|
367
|
+
this.stopRepeat();
|
|
368
|
+
const target = event.currentTarget as HTMLElement;
|
|
369
|
+
const stop = () => this.stopRepeat();
|
|
370
|
+
for (const type of ['pointerup', 'pointerleave', 'pointercancel']) {
|
|
371
|
+
target.addEventListener(type, stop, { once: true });
|
|
372
|
+
}
|
|
373
|
+
const repeat = () => {
|
|
374
|
+
if (!this.canStep(direction)) return this.stopRepeat();
|
|
375
|
+
this.stepBy(direction * this.step());
|
|
376
|
+
this.repeatTimer = setTimeout(repeat, REPEAT_EVERY);
|
|
377
|
+
};
|
|
378
|
+
this.repeatTimer = setTimeout(repeat, REPEAT_DELAY);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Clic sin puntero (un lector de pantalla): un paso. Tras un `pointerdown`, ya se contó. */
|
|
382
|
+
protected tap(direction: 1 | -1): void {
|
|
383
|
+
if (this.pointerStepped) {
|
|
384
|
+
this.pointerStepped = false;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (this.canStep(direction)) this.stepBy(direction * this.step());
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** El foco salió de la caja entera (input y botones). */
|
|
391
|
+
protected onFocusOut(event: FocusEvent): void {
|
|
392
|
+
const next = event.relatedTarget as Node | null;
|
|
393
|
+
if (next && this.host.contains(next)) return;
|
|
394
|
+
this.touch.emit();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private stopRepeat(): void {
|
|
398
|
+
clearTimeout(this.repeatTimer);
|
|
399
|
+
this.repeatTimer = undefined;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private canStep(direction: 1 | -1): boolean {
|
|
403
|
+
if (this.disabled() || this.readonly()) return false;
|
|
404
|
+
const shown = this.shown();
|
|
405
|
+
if (shown === null) return true;
|
|
406
|
+
const limit = direction > 0 ? this.max() : this.min();
|
|
407
|
+
return limit === undefined || (direction > 0 ? shown < limit : shown > limit);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Suma `delta` al valor (o al texto que se está escribiendo), acotado. */
|
|
411
|
+
private stepBy(delta: number): void {
|
|
412
|
+
if (this.disabled() || this.readonly()) return;
|
|
413
|
+
const current = this.editing() ? this.parse(this.draft()) : this.shown();
|
|
414
|
+
// Vacío (o un texto que no es un número): el primer paso lleva a `min` (o a 0).
|
|
415
|
+
const empty = current === null || Number.isNaN(current);
|
|
416
|
+
this.setFromKeyboard(empty ? (this.min() ?? 0) : this.roundToStep(current) + delta);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private setFromKeyboard(value: number): void {
|
|
420
|
+
const next = this.clamp(this.fix(value));
|
|
421
|
+
if (next !== this.value()) this.value.set(next);
|
|
422
|
+
if (this.editing()) this.draft.set(this.plain().format(next));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private clamp(value: number): number {
|
|
426
|
+
const min = this.min();
|
|
427
|
+
const max = this.max();
|
|
428
|
+
let v = value;
|
|
429
|
+
if (max !== undefined) v = Math.min(max, v);
|
|
430
|
+
if (min !== undefined) v = Math.max(min, v);
|
|
431
|
+
return v;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Al múltiplo del paso más cercano, contado desde `min` (o 0). */
|
|
435
|
+
private roundToStep(value: number): number {
|
|
436
|
+
const step = this.step();
|
|
437
|
+
const base = this.min() ?? 0;
|
|
438
|
+
return this.fix(base + Math.round((value - base) / step) * step);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Quita el error de coma flotante: los decimales del paso, de `min` y de `max`. */
|
|
442
|
+
private fix(value: number): number {
|
|
443
|
+
const places = Math.max(
|
|
444
|
+
decimals(this.step()),
|
|
445
|
+
decimals(this.min() ?? 0),
|
|
446
|
+
decimals(this.max() ?? 0),
|
|
447
|
+
);
|
|
448
|
+
return Number(value.toFixed(Math.min(places, 20)));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Solo la API pública: MimiOptionList, MimiListOutlet y las funciones de secciones son internos
|
|
2
|
+
// (se quedan en './option-list', para los componentes de Mimi que tienen lista).
|
|
3
|
+
export {
|
|
4
|
+
MimiHighlight,
|
|
5
|
+
type MimiListFilter,
|
|
6
|
+
type MimiListOption,
|
|
7
|
+
type MimiListOptionContext,
|
|
8
|
+
type MimiListOptionGroup,
|
|
9
|
+
mimiDefaultListFilter,
|
|
10
|
+
mimiNormalizeText,
|
|
11
|
+
} from './option-list';
|