@moni-labs/moni-ui 0.4.13 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/custom-elements.json +101 -81
  2. package/dist/browser/moni-ui.iife.min.js +1538 -1384
  3. package/dist/browser/moni-ui.iife.min.js.br +0 -0
  4. package/dist/browser/moni-ui.iife.min.js.gz +0 -0
  5. package/dist/browser/moni-ui.iife.min.js.map +1 -1
  6. package/dist/browser/moni-ui.min.js +2751 -2501
  7. package/dist/browser/moni-ui.min.js.br +0 -0
  8. package/dist/browser/moni-ui.min.js.gz +0 -0
  9. package/dist/browser/moni-ui.min.js.map +1 -1
  10. package/dist/cdn/chunks/{class-map-DsL1Jr2T.js → class-map-QyT9W7rZ.js} +1 -1
  11. package/dist/cdn/chunks/{decorate-DxJYS4gw.js → decorate-B7wZvwrn.js} +629 -543
  12. package/dist/cdn/chunks/if-defined-5hr_ccFw.js +1 -0
  13. package/dist/cdn/chunks/{live-D52JqeKf.js → live-Hm1Di_ND.js} +1 -1
  14. package/dist/cdn/components/moni-app-bar.js +1 -1
  15. package/dist/cdn/components/moni-badge.js +1 -1
  16. package/dist/cdn/components/moni-bottom-sheet.js +2 -1
  17. package/dist/cdn/components/moni-button-group.js +1 -1
  18. package/dist/cdn/components/moni-button-segment.js +1 -1
  19. package/dist/cdn/components/moni-button.js +1 -1
  20. package/dist/cdn/components/moni-card.js +1 -1
  21. package/dist/cdn/components/moni-carousel.js +1 -1
  22. package/dist/cdn/components/moni-checkbox.js +1 -1
  23. package/dist/cdn/components/moni-chip.js +1 -1
  24. package/dist/cdn/components/moni-color-field.js +1 -1
  25. package/dist/cdn/components/moni-context-menu.js +1 -1
  26. package/dist/cdn/components/moni-dialog.js +1 -1
  27. package/dist/cdn/components/moni-divider.js +1 -1
  28. package/dist/cdn/components/moni-expansion.js +1 -1
  29. package/dist/cdn/components/moni-fab-menu.js +1 -1
  30. package/dist/cdn/components/moni-fab.js +1 -1
  31. package/dist/cdn/components/moni-file-field.js +1 -1
  32. package/dist/cdn/components/moni-icon.js +1 -1
  33. package/dist/cdn/components/moni-list-item.js +1 -1
  34. package/dist/cdn/components/moni-list.js +1 -1
  35. package/dist/cdn/components/moni-loading-indicator.js +1 -1
  36. package/dist/cdn/components/moni-menu-item.js +1 -1
  37. package/dist/cdn/components/moni-menu.js +1 -1
  38. package/dist/cdn/components/moni-morph-modal.js +3 -2
  39. package/dist/cdn/components/moni-nav-item.js +1 -1
  40. package/dist/cdn/components/moni-nav.js +1 -1
  41. package/dist/cdn/components/moni-progress.js +1 -1
  42. package/dist/cdn/components/moni-radio.js +1 -1
  43. package/dist/cdn/components/moni-ripple.js +1 -1
  44. package/dist/cdn/components/moni-segmented-button.js +1 -1
  45. package/dist/cdn/components/moni-select-option.js +1 -1
  46. package/dist/cdn/components/moni-select.js +512 -473
  47. package/dist/cdn/components/moni-shape.js +1 -1
  48. package/dist/cdn/components/moni-side-sheet.js +1 -1
  49. package/dist/cdn/components/moni-slider.js +1 -1
  50. package/dist/cdn/components/moni-snackbar.js +1 -1
  51. package/dist/cdn/components/moni-split-button.js +1 -1
  52. package/dist/cdn/components/moni-step.js +1 -1
  53. package/dist/cdn/components/moni-stepper.js +1 -1
  54. package/dist/cdn/components/moni-switch.js +1 -1
  55. package/dist/cdn/components/moni-tab.js +1 -1
  56. package/dist/cdn/components/moni-tabs.js +1 -1
  57. package/dist/cdn/components/moni-text-field.js +122 -95
  58. package/dist/cdn/components/moni-textarea.js +52 -52
  59. package/dist/cdn/components/moni-time-picker.js +1 -1
  60. package/dist/cdn/components/moni-toolbar.js +1 -1
  61. package/dist/cdn/components/moni-tooltip.js +1 -1
  62. package/dist/cdn/components/moni-typography.js +1 -1
  63. package/dist/cdn/importmap.json +98 -98
  64. package/dist/components/_base/field-styles.d.ts.map +1 -1
  65. package/dist/components/_base/field-styles.js +662 -576
  66. package/dist/components/moni-bottom-sheet.d.ts +19 -5
  67. package/dist/components/moni-bottom-sheet.d.ts.map +1 -1
  68. package/dist/components/moni-bottom-sheet.js +111 -30
  69. package/dist/components/moni-morph-modal.d.ts +10 -0
  70. package/dist/components/moni-morph-modal.d.ts.map +1 -1
  71. package/dist/components/moni-morph-modal.js +323 -217
  72. package/dist/components/moni-select.d.ts +16 -16
  73. package/dist/components/moni-select.d.ts.map +1 -1
  74. package/dist/components/moni-select.js +797 -727
  75. package/dist/components/moni-text-field.d.ts +15 -15
  76. package/dist/components/moni-text-field.d.ts.map +1 -1
  77. package/dist/components/moni-text-field.js +224 -180
  78. package/dist/components/moni-textarea.d.ts +13 -13
  79. package/dist/components/moni-textarea.d.ts.map +1 -1
  80. package/dist/components/moni-textarea.js +127 -122
  81. package/docs/api.json +109 -89
  82. package/docs/assets/moni-color-field/shape--square.png +0 -0
  83. package/docs/assets/moni-file-field/shape--square.png +0 -0
  84. package/docs/assets/moni-morph-modal/autoHeight--false.png +0 -0
  85. package/docs/assets/moni-morph-modal/autoHeight--true.png +0 -0
  86. package/docs/components/bottom-sheet.md +2 -1
  87. package/docs/components/morph-modal.md +16 -7
  88. package/docs/components/select.md +74 -74
  89. package/docs/components/text-field.md +75 -66
  90. package/docs/components/textarea.md +51 -51
  91. package/docs/llms.txt +3 -3
  92. package/package.json +1 -1
  93. package/src/components/_base/field-styles.ts +723 -637
  94. package/src/components/moni-bottom-sheet.test.ts +331 -0
  95. package/src/components/moni-bottom-sheet.ts +117 -29
  96. package/src/components/moni-morph-modal.test.ts +490 -413
  97. package/src/components/moni-morph-modal.ts +2274 -2162
  98. package/src/components/moni-select.test.ts +489 -466
  99. package/src/components/moni-select.ts +1963 -1815
  100. package/src/components/moni-text-field.test.ts +366 -342
  101. package/src/components/moni-text-field.ts +717 -645
  102. package/src/components/moni-textarea.test.ts +201 -197
  103. package/src/components/moni-textarea.ts +476 -460
  104. package/dist/cdn/chunks/if-defined-DdzvcoI7.js +0 -1
@@ -1,645 +1,717 @@
1
- /**
2
- * @file components/moni-text-field.ts
3
- * @package @moni-labs/moni-ui
4
- * @license MIT
5
- * @contributors Moni Labs & Contributors
6
- */
7
-
8
- import { css, html, nothing } from 'lit';
9
- import { customElement, property, query, state } from 'lit/decorators.js';
10
- import { ifDefined } from 'lit/directives/if-defined.js';
11
- import { classMap } from 'lit/directives/class-map.js';
12
- import Inputmask, { type InputmaskInstance, type InputmaskOptions } from 'inputmask';
13
- import { MoniElement, sharedStyles, fieldStyles } from './_base/index.js';
14
- import { emitMoniEvent } from '../utils/event-emitter.js';
15
- import './moni-icon.js';
16
- import './moni-progress.js';
17
-
18
- /**
19
- * Componente Material Design 3 Text Field (Campo de texto).
20
- *
21
- * Un campo de entrada con todas las funciones que envuelve un `<input>` nativo dentro de la
22
- * estructura de campo M3 (clase `.field` de `fieldStyles`). Soporta etiquetas flotantes,
23
- * variantes llenas (filled) y contorneadas (outlined), iconos iniciales/finales (leading/trailing),
24
- * texto de ayuda y estados de error.
25
- *
26
- * **Referencia a la especificación M3:** `m3-docs/components/text-fields/specs.md`
27
- *
28
- * **Arquitectura visual:**
29
- * Utiliza `fieldStyles` para toda la estructura CSS del campo. El contenedor del campo
30
- * es un `<div class="field [modifiers]">` que envuelve:
31
- * 1. Icono inicial (leading) opcional.
32
- * 2. Elemento `<input>` nativo.
33
- * 3. `<label>` flotante (cuando se establece `label`).
34
- * 4. Icono final (trailing) opcional o indicador de carga (spinner).
35
- * 5. `<output>` para texto de ayuda/error.
36
- *
37
- * **Sincronización de eventos:**
38
- * Emite `moni-input` en cada tipeo y `moni-change` al consolidar el valor (blur/enter).
39
- * El valor interno del componente (`this.value`) se mantiene sincronizado automáticamente.
40
- *
41
- * @example
42
- * ```html
43
- * <moni-text-field
44
- * label="Dirección de correo"
45
- * type="email"
46
- * name="email"
47
- * icon="mail"
48
- * variant="outlined"
49
- * helper="Nunca compartiremos tu correo."
50
- * ></moni-text-field>
51
- *
52
- * <moni-text-field
53
- * label="Monto"
54
- * type="number"
55
- * prefix="$"
56
- * error
57
- * error-text="El valor debe ser positivo"
58
- * ></moni-text-field>
59
- * ```
60
- *
61
- * @csspart field - El contenedor div `.field` exterior.
62
- * @csspart input - El elemento `<input>` nativo.
63
- * @csspart label - El elemento `<label>` flotante.
64
- * @csspart helper - El elemento `<output>` de ayuda.
65
- * @csspart error-output - El elemento `<output>` de error.
66
- */
67
- @customElement('moni-text-field')
68
- export class MoniTextField extends MoniElement {
69
- static formAssociated = true;
70
- /** Constructor original para acceder también a la API estática completa de Inputmask. */
71
- static readonly Inputmask = Inputmask;
72
- private _internals: ElementInternals;
73
-
74
- constructor() {
75
- super();
76
- this._internals = this.attachInternals();
77
- }
78
-
79
- /**
80
- * El nombre del input, enviado con los datos del formulario.
81
- * @type {string}
82
- */
83
- @property({ reflect: true }) name = '';
84
-
85
- /**
86
- * El texto de la etiqueta flotante.
87
- * @type {string}
88
- */
89
- @property({ reflect: true }) label = '';
90
-
91
- /**
92
- * Variante visual del campo de texto.
93
- * @type {'filled' | 'outlined' | 'underlined'}
94
- * @default 'filled'
95
- */
96
- @property({ reflect: true })
97
- variant: 'filled' | 'outlined' | 'underlined' = 'filled';
98
-
99
- /**
100
- * Define las dimensiones del campo de texto.
101
- * @type {'small' | 'medium' | 'large' | 'extra'}
102
- * @default 'medium'
103
- */
104
- @property({ reflect: true })
105
- size: 'small' | 'medium' | 'large' | 'extra' = 'medium';
106
-
107
- /**
108
- * Forma del radio del borde (border-radius) del campo.
109
- * @type {'round' | 'small-round' | 'no-round'}
110
- * @default 'no-round'
111
- */
112
- @property({ reflect: true })
113
- shape: 'round' | 'small-round' | 'no-round' = 'no-round';
114
-
115
- /**
116
- * El tipo de input HTML nativo.
117
- * @type {'text' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local' | 'month' | 'week' | 'color'}
118
- * @default 'text'
119
- */
120
- @property({ reflect: true })
121
- type:
122
- | 'text'
123
- | 'password'
124
- | 'email'
125
- | 'number'
126
- | 'tel'
127
- | 'url'
128
- | 'search'
129
- | 'date'
130
- | 'time'
131
- | 'datetime-local'
132
- | 'month'
133
- | 'week'
134
- | 'color' = 'text';
135
-
136
- /**
137
- * Nombre del icono inicial (leading) (Material Symbols).
138
- * @type {string}
139
- */
140
- @property({ reflect: true }) icon = '';
141
-
142
- /**
143
- * Nombre del icono final (trailing) (Material Symbols).
144
- * @type {string}
145
- */
146
- @property({ reflect: true, attribute: 'trailing-icon' }) trailingIcon = '';
147
-
148
- /**
149
- * Prefijo de texto corto mostrado antes del valor del input.
150
- * @type {string}
151
- */
152
- @property({ reflect: true }) prefix = '';
153
-
154
- /**
155
- * Sufijo de texto corto mostrado después del valor del input.
156
- * @type {string}
157
- */
158
- @property({ reflect: true }) suffix = '';
159
-
160
- /** Icono del botón interactivo ubicado al final del campo. */
161
- @property({ reflect: true, attribute: 'suffix-button-icon' }) suffixButtonIcon = '';
162
-
163
- /** Etiqueta accesible del botón suffix. */
164
- @property({ reflect: true, attribute: 'suffix-button-label' }) suffixButtonLabel = 'Acción del campo';
165
-
166
- @state() private _hasSuffixSlot = false;
167
-
168
- /**
169
- * Texto de ayuda mostrado debajo del campo.
170
- * @type {string}
171
- */
172
- @property({ reflect: true }) helper = '';
173
-
174
- /**
175
- * Texto de error mostrado debajo del campo cuando `error` es true.
176
- * Sobrescribe el texto de ayuda.
177
- * @type {string}
178
- */
179
- @property({ reflect: true, attribute: 'error-text' }) errorText = '';
180
-
181
- /**
182
- * Si es true, establece el campo en un estado de error.
183
- * @type {boolean}
184
- */
185
- @property({ type: Boolean, reflect: true }) error = false;
186
-
187
- /**
188
- * Si es true, muestra un indicador de carga (progreso lineal/circular) al final.
189
- * @type {boolean}
190
- */
191
- @property({ type: Boolean, reflect: true }) loading = false;
192
-
193
- /**
194
- * Deshabilita el campo de texto.
195
- * @type {boolean}
196
- */
197
- @property({ type: Boolean, reflect: true }) disabled = false;
198
-
199
- /**
200
- * El valor actual del input.
201
- * @type {string}
202
- */
203
- @property({ reflect: true }) value = '';
204
-
205
- /**
206
- * Texto de marcador de posición (placeholder) mostrado cuando el input está vacío y la etiqueta es flotante.
207
- * @type {string}
208
- */
209
- @property({ reflect: true }) placeholder = '';
210
-
211
- /*
212
- * Restricciones nativas.
213
- *
214
- * Antes ninguna de estas llegaba al `<input>` interno: el componente sólo
215
- * reenviaba `type`, `placeholder`, `disabled`, `value` y `name`. Eso dejaba
216
- * fuera el autocompletado de contraseñas, el teclado correcto en móvil, el
217
- * límite de caracteres y toda la validación de restricciones, así que cada
218
- * consumidor que necesitaba una de ellas tenía que renunciar al componente y
219
- * usar un input nativo.
220
- *
221
- * Se reflejan al host además de reenviarse para que `[required]` y compañía
222
- * puedan usarse como selectores CSS desde fuera.
223
- */
224
-
225
- /** Marca el campo como obligatorio y lo integra en la validación del formulario. */
226
- @property({ type: Boolean, reflect: true }) required = false;
227
-
228
- /** Impide editar el valor sin sacarlo del envío ni atenuarlo como `disabled`. */
229
- @property({ type: Boolean, reflect: true }) readonly = false;
230
-
231
- /** Pista de autocompletado del navegador, por ejemplo `email` o `current-password`. */
232
- @property({ reflect: true }) autocomplete = '';
233
-
234
- /** Teclado virtual a mostrar en móvil, por ejemplo `numeric` o `tel`. */
235
- @property({ reflect: true }) inputmode = '';
236
-
237
- /** Máximo de caracteres aceptados. */
238
- @property({ type: Number, reflect: true }) maxlength: number | null = null;
239
-
240
- /** Mínimo de caracteres aceptados. */
241
- @property({ type: Number, reflect: true }) minlength: number | null = null;
242
-
243
- /** Valor mínimo para `number` y los tipos de fecha y hora. */
244
- @property({ reflect: true }) min = '';
245
-
246
- /** Valor máximo para `number` y los tipos de fecha y hora. */
247
- @property({ reflect: true }) max = '';
248
-
249
- /** Incremento para `number` y los tipos de fecha y hora. */
250
- @property({ reflect: true }) step = '';
251
-
252
- /** Expresión regular que debe cumplir el valor. */
253
- @property({ reflect: true }) pattern = '';
254
-
255
- /**
256
- * Patrón Inputmask. Admite opcionales `[]`, grupos `()`, alternadores `|`,
257
- * cuantificadores `{n,m}` y los tokens `9`, `a`, `*` y `K` (RUT).
258
- * @example `99.999.999-K`
259
- */
260
- @property({ reflect: true }) mask = '';
261
-
262
- /** Alias integrado de Inputmask, por ejemplo `email`, `datetime`, `numeric`, `currency` o `ip`. */
263
- @property({ reflect: true, attribute: 'mask-alias' }) maskAlias = '';
264
-
265
- /** Opciones avanzadas de Inputmask. También acepta JSON mediante el atributo `mask-options`. */
266
- @property({
267
- attribute: 'mask-options',
268
- converter: {
269
- fromAttribute: (value: string | null) => {
270
- if (!value) return {};
271
- try { return JSON.parse(value) as InputmaskOptions; } catch { return {}; }
272
- },
273
- toAttribute: (value: InputmaskOptions) => JSON.stringify(value ?? {})
274
- }
275
- })
276
- maskOptions: InputmaskOptions = {};
277
-
278
- /** Valor actual sin los caracteres literales definidos por `mask`. */
279
- get unmaskedValue(): string {
280
- return this._maskInstance?.unmaskedvalue() ?? this.value;
281
- }
282
-
283
- @query('input') private _input!: HTMLInputElement;
284
- private _maskInstance?: InputmaskInstance;
285
-
286
- /** Instancia original de Inputmask aplicada al input nativo. */
287
- get inputmaskInstance(): InputmaskInstance | undefined {
288
- return this._maskInstance;
289
- }
290
-
291
- /** Lee o actualiza opciones de la instancia activa. */
292
- maskOption(name: string): unknown;
293
- maskOption(options: InputmaskOptions, noRemask?: boolean): InputmaskInstance | undefined;
294
- maskOption(nameOrOptions: string | InputmaskOptions, noRemask = false): unknown {
295
- if (!this._maskInstance) return undefined;
296
- return typeof nameOrOptions === 'string'
297
- ? this._maskInstance.option(nameOrOptions)
298
- : this._maskInstance.option(nameOrOptions, noRemask);
299
- }
300
-
301
- getEmptyMask(): string {
302
- return this._maskInstance?.getemptymask() ?? '';
303
- }
304
-
305
- hasMaskedValue(): boolean {
306
- return this._maskInstance?.hasMaskedValue() ?? false;
307
- }
308
-
309
- isMaskComplete(): boolean {
310
- return this._maskInstance?.isComplete() ?? true;
311
- }
312
-
313
- isMaskValid(value?: string): boolean {
314
- return this._maskInstance?.isValid(value) ?? true;
315
- }
316
-
317
- getMaskMetadata(): unknown {
318
- return this._maskInstance?.getmetadata();
319
- }
320
-
321
- formatWithMask(value: string, metadata = false): string | { value: string; metadata: unknown } {
322
- return this._maskInstance?.format(value, metadata) ?? value;
323
- }
324
-
325
- setMaskedValue(value: string): void {
326
- if (!this._maskInstance) {
327
- this.value = value;
328
- return;
329
- }
330
- this._maskInstance.setValue(value);
331
- this.value = this._input.value;
332
- this._internals?.setFormValue?.(this.value);
333
- }
334
-
335
- removeMask(): void {
336
- this._maskInstance?.remove();
337
- this._maskInstance = undefined;
338
- }
339
-
340
- private _configureMask() {
341
- this._maskInstance?.remove();
342
- this._maskInstance = undefined;
343
- if (!this._input || (!this.mask && !this.maskAlias)) return;
344
-
345
- const options: InputmaskOptions = {
346
- placeholder: '',
347
- ...this.maskOptions,
348
- ...(this.mask ? { mask: this.mask } : {}),
349
- definitions: {
350
- ...(this.maskOptions.definitions ?? {}),
351
- K: { validator: '[0-9Kk]', casing: 'upper' }
352
- }
353
- };
354
- const inputmask = this.maskAlias
355
- ? new Inputmask(this.maskAlias, options)
356
- : new Inputmask(options);
357
- this._maskInstance = inputmask.mask(this._input);
358
- }
359
-
360
- /**
361
- * Sincroniza imperativamente las propiedades DOM del `<input>` nativo después de cada actualización reactiva.
362
- *
363
- * Las vinculaciones `?value` y `?disabled` de Lit establecen *atributos* HTML, no *propiedades* DOM.
364
- * Para un `<input>`, el atributo `value` solo establece el valor *inicial*; la propiedad DOM
365
- * `input.value` refleja el valor actual tecleado por el usuario. Después de una actualización reactiva que
366
- * cambia `this.value` (ej. reinicio programático), debemos escribir directamente a la propiedad
367
- * DOM para sobrescribir lo que el usuario ha tecleado. Esto también es crítico para los consumidores
368
- * de React y Vue que mutan la propiedad del Custom Element directamente, omitiendo el flujo
369
- * de atributos de Lit.
370
- *
371
- * @param changed - Mapa de los nombres de propiedades a sus valores anteriores que causaron esta actualización.
372
- */
373
- override updated(changed: Map<string, unknown>) {
374
- if (this._input) {
375
- if (changed.has('mask') || changed.has('maskAlias') || changed.has('maskOptions')) {
376
- this._configureMask();
377
- }
378
- if (changed.has('value')) {
379
- this._input.value = this.value;
380
- if (this._maskInstance && this.value !== this._input.value) {
381
- this.value = this._input.value;
382
- }
383
- this._internals?.setFormValue?.(this.value);
384
- }
385
- if (changed.has('disabled')) this._input.disabled = this.disabled;
386
- }
387
- this._syncValidity();
388
- }
389
-
390
- /**
391
- * Copia la validez del `<input>` interno al host.
392
- *
393
- * Reenviar `required` o `pattern` no basta: el navegador valida el
394
- * formulario mirando los controles asociados a él, y el que aquí participa
395
- * es el host, no el input del Shadow DOM. Sin este puente el campo se
396
- * pintaría como obligatorio pero el envío no se detendría.
397
- *
398
- * El tercer argumento ancla el globo de validación al input real, para que
399
- * el navegador tenga dónde mostrarlo y a dónde mover el foco.
400
- */
401
- /**
402
- * API de validación equivalente a la de un control nativo.
403
- *
404
- * Los elementos form-associated participan en la validación del formulario,
405
- * pero no reciben estos miembros automáticamente: hay que exponerlos para que
406
- * un `moni-text-field` se pueda interrogar igual que un `<input>`.
407
- */
408
- get validity(): ValidityState {
409
- return this._internals.validity;
410
- }
411
-
412
- get validationMessage(): string {
413
- return this._internals.validationMessage;
414
- }
415
-
416
- checkValidity(): boolean {
417
- return this._internals.checkValidity();
418
- }
419
-
420
- reportValidity(): boolean {
421
- return this._internals.reportValidity();
422
- }
423
-
424
- private _syncValidity() {
425
- if (!this._input || !this._internals?.setValidity) return;
426
- this._internals.setValidity(
427
- this._input.validity,
428
- this._input.validationMessage,
429
- this._input
430
- );
431
- }
432
-
433
- private _handleInput(e: Event) {
434
- const target = e.target as HTMLInputElement;
435
- this.value = target.value;
436
- // Al teclear, `value` no siempre cambia de referencia (máscaras), así que
437
- // la validez se refresca aquí y no sólo desde `updated()`.
438
- this._syncValidity();
439
- emitMoniEvent(this, 'moni-input', {
440
- detail: { value: this.value, unmaskedValue: this.unmaskedValue, originalEvent: e }
441
- });
442
- }
443
-
444
- private _handleChange(e: Event) {
445
- const target = e.target as HTMLInputElement;
446
- this.value = target.value;
447
- emitMoniEvent(this, 'moni-change', {
448
- detail: { value: this.value, unmaskedValue: this.unmaskedValue, originalEvent: e }
449
- });
450
- }
451
-
452
- override disconnectedCallback() {
453
- this._maskInstance?.remove();
454
- this._maskInstance = undefined;
455
- super.disconnectedCallback();
456
- }
457
-
458
- private _handleSuffixClick(event: Event) {
459
- if (this.disabled) return;
460
- this.dispatchEvent(new CustomEvent('suffix-click', {
461
- detail: { originalEvent: event },
462
- bubbles: true,
463
- composed: true
464
- }));
465
- }
466
-
467
- private _handleSuffixSlotChange(event: Event) {
468
- const slot = event.currentTarget as HTMLSlotElement;
469
- this._hasSuffixSlot = slot.assignedElements({ flatten: true }).length > 0;
470
- }
471
-
472
- static override styles = [sharedStyles, fieldStyles, css`
473
- .suffix-action {
474
- position: absolute;
475
- inset-inline-end: .5rem;
476
- inset-block-start: calc(var(--_middle) - 1.25rem);
477
- z-index: 3;
478
- display: grid;
479
- place-items: center;
480
- inline-size: 2.5rem;
481
- block-size: 2.5rem;
482
- pointer-events: auto;
483
- }
484
- .suffix-action[hidden] { display: none; }
485
- .suffix-button {
486
- all: unset;
487
- box-sizing: border-box;
488
- display: grid;
489
- place-items: center;
490
- inline-size: 2.5rem;
491
- block-size: 2.5rem;
492
- border-radius: 50%;
493
- color: var(--on-surface-variant);
494
- cursor: pointer;
495
- transition: background-color 150ms ease, transform 150ms ease;
496
- }
497
- .suffix-button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
498
- .suffix-button:active { background: color-mix(in srgb, currentColor 12%, transparent); transform: scale(.92); }
499
- .suffix-button:focus-visible { outline: .125rem solid var(--primary); outline-offset: .125rem; }
500
- .suffix-button:disabled { opacity: .38; cursor: default; }
501
- .suffix-button moni-icon { --moni-icon-size: 1.5rem; }
502
- ::slotted([slot='suffix']) { pointer-events: auto; }
503
- .field.suffix-action-field > input { padding-inline-end: 3.5rem; }
504
- .field.suffix-action-field > input:focus { padding-inline-end: 3.4375rem; }
505
- `];
506
-
507
- /**
508
- * Renderiza el campo de texto con animación de etiqueta flotante, iconos iniciales/finales, y texto de ayuda.
509
- *
510
- * **Composición de `fieldClasses`:**
511
- * - `field` siempre presente; diseño base del campo.
512
- * - `label` — habilita el comportamiento de la etiqueta flotante vía CSS.
513
- * - `fill` / `border` — variantes filled y outlined; underlined no agrega ninguna.
514
- * - `small` / `large` / `extra` — modificador de tamaño.
515
- * - `prefix` — desplaza la etiqueta cuando hay un icono inicial (leading) presente.
516
- * - `suffix` — reserva espacio final (trailing) para el icono/texto de sufijo.
517
- * - `invalid` — aplica colores de error cuando `error=true` o falla la validación nativa.
518
- *
519
- * **Flotación de la etiqueta (Label float):**
520
- * `isActive` es true cuando `value` no está vacío o se establece un `placeholder`.
521
- * Los selectores CSS `.field.label input:focus ~ label`, `.field.label.active label`
522
- * flotan la etiqueta a la parte superior del borde del campo. Este componente
523
- * establece `isActive` para evitar que la etiqueta colapse cuando un valor está preestablecido.
524
- *
525
- * **Zonas inicial / final (Leading / trailing):**
526
- * `hasLeading` y `hasTrailing` añaden condicionalmente las clases CSS `prefix`/`suffix`
527
- * y renderizan el icono o los elementos de texto de prefijo/sufijo.
528
- */
529
- override render() {
530
- const hasLeading = Boolean(this.icon) || Boolean(this.prefix);
531
- const hasTrailing =
532
- Boolean(this.trailingIcon) || Boolean(this.suffix) || Boolean(this.suffixButtonIcon) || this._hasSuffixSlot;
533
- const isActive = Boolean(this.value) || Boolean(this.placeholder);
534
- const fieldClasses = {
535
- field: true,
536
- label: Boolean(this.label),
537
- fill: this.variant === 'filled',
538
- border: this.variant === 'outlined',
539
- small: this.size === 'small',
540
- large: this.size === 'large',
541
- extra: this.size === 'extra',
542
- prefix: hasLeading,
543
- suffix: hasTrailing,
544
- 'suffix-action-field': Boolean(this.suffixButtonIcon) || this._hasSuffixSlot,
545
- invalid: this.error,
546
- round: this.shape === 'round' && this.variant !== 'underlined',
547
- 'small-round': this.shape === 'small-round' && this.variant !== 'underlined',
548
- square: this.shape === 'no-round'
549
- };
550
- const placeholder = this.placeholder || (this.label ? ' ' : '');
551
-
552
- const leading = this.icon
553
- ? html`<i class="leading-icon" part="leading-icon"
554
- ><moni-icon name="${this.icon}"></moni-icon
555
- ></i>`
556
- : this.prefix
557
- ? html`<span class="leading-icon" part="prefix"
558
- >${this.prefix}</span
559
- >`
560
- : nothing;
561
-
562
- const trailing = this.loading
563
- ? html`<i class="trailing-icon" part="trailing-icon"
564
- ><moni-progress
565
- variant="circular"
566
- indeterminate
567
- size="small"
568
- style="inline-size: 1.25rem; block-size: 1.25rem; color: currentColor;"
569
- ></moni-progress
570
- ></i>`
571
- : this.suffixButtonIcon
572
- ? html`<span class="suffix-action" part="suffix-action">
573
- <button
574
- type="button"
575
- class="suffix-button"
576
- part="suffix-button"
577
- aria-label=${this.suffixButtonLabel}
578
- ?disabled=${this.disabled}
579
- @click=${this._handleSuffixClick}
580
- ><moni-icon name=${this.suffixButtonIcon}></moni-icon></button>
581
- </span>`
582
- : this.trailingIcon
583
- ? html`<i class="trailing-icon" part="trailing-icon"
584
- ><moni-icon name="${this.trailingIcon}"></moni-icon
585
- ></i>`
586
- : this.suffix
587
- ? html`<span class="trailing-icon" part="suffix"
588
- >${this.suffix}</span
589
- >`
590
- : nothing;
591
-
592
- return html`<div class=${classMap(fieldClasses)} part="field">
593
- ${leading}
594
- <input
595
- id="input"
596
- part="input"
597
- type=${this.type}
598
- placeholder=${placeholder}
599
- ?disabled=${this.disabled}
600
- ?required=${this.required}
601
- ?readonly=${this.readonly}
602
- autocomplete=${ifDefined(this.autocomplete || undefined)}
603
- inputmode=${ifDefined(this.inputmode || undefined)}
604
- maxlength=${ifDefined(this.maxlength ?? undefined)}
605
- minlength=${ifDefined(this.minlength ?? undefined)}
606
- min=${ifDefined(this.min || undefined)}
607
- max=${ifDefined(this.max || undefined)}
608
- step=${ifDefined(this.step || undefined)}
609
- pattern=${ifDefined(this.pattern || undefined)}
610
- .value=${this.value}
611
- name=${ifDefined(this.name || undefined)}
612
- class=${isActive ? 'active' : ''}
613
- @input=${this._handleInput}
614
- @change=${this._handleChange}
615
- />
616
- ${this.label
617
- ? html`<label
618
- for="input"
619
- part="label"
620
- class=${classMap({ active: isActive })}
621
- >${this.label}</label
622
- >`
623
- : nothing}
624
- ${trailing}
625
- <span class="suffix-action" part="suffix-action" ?hidden=${Boolean(this.suffixButtonIcon) || !this._hasSuffixSlot}>
626
- <slot name="suffix" @slotchange=${this._handleSuffixSlotChange}></slot>
627
- </span>
628
- ${this.error
629
- ? html`<output part="helper" class="invalid"
630
- >${this.errorText || this.helper}</output
631
- >`
632
- : this.helper
633
- ? html`<output part="helper">${this.helper}</output>`
634
- : nothing}
635
- </div>`;
636
- }
637
- }
638
-
639
- declare global {
640
- interface HTMLElementTagNameMap {
641
- 'moni-text-field': MoniTextField;
642
- }
643
- }
644
-
645
- export default MoniTextField;
1
+ /**
2
+ * @file components/moni-text-field.ts
3
+ * @package @moni-labs/moni-ui
4
+ * @license MIT
5
+ * @contributors Moni Labs & Contributors
6
+ */
7
+
8
+ import { css, html, nothing } from "lit";
9
+ import { customElement, property, query, state } from "lit/decorators.js";
10
+ import { ifDefined } from "lit/directives/if-defined.js";
11
+ import { classMap } from "lit/directives/class-map.js";
12
+ import Inputmask, {
13
+ type InputmaskInstance,
14
+ type InputmaskOptions,
15
+ } from "inputmask";
16
+ import { MoniElement, sharedStyles, fieldStyles } from "./_base/index.js";
17
+ import { emitMoniEvent } from "../utils/event-emitter.js";
18
+ import "./moni-icon.js";
19
+ import "./moni-progress.js";
20
+
21
+ /**
22
+ * Componente Material Design 3 Text Field (Campo de texto).
23
+ *
24
+ * Un campo de entrada con todas las funciones que envuelve un `<input>` nativo dentro de la
25
+ * estructura de campo M3 (clase `.field` de `fieldStyles`). Soporta etiquetas flotantes,
26
+ * variantes llenas (filled) y contorneadas (outlined), iconos iniciales/finales (leading/trailing),
27
+ * texto de ayuda y estados de error.
28
+ *
29
+ * **Referencia a la especificación M3:** `m3-docs/components/text-fields/specs.md`
30
+ *
31
+ * **Arquitectura visual:**
32
+ * Utiliza `fieldStyles` para toda la estructura CSS del campo. El contenedor del campo
33
+ * es un `<div class="field [modifiers]">` que envuelve:
34
+ * 1. Icono inicial (leading) opcional.
35
+ * 2. Elemento `<input>` nativo.
36
+ * 3. `<label>` flotante (cuando se establece `label`).
37
+ * 4. Icono final (trailing) opcional o indicador de carga (spinner).
38
+ * 5. `<output>` para texto de ayuda/error.
39
+ *
40
+ * **Sincronización de eventos:**
41
+ * Emite `moni-input` en cada tipeo y `moni-change` al consolidar el valor (blur/enter).
42
+ * El valor interno del componente (`this.value`) se mantiene sincronizado automáticamente.
43
+ *
44
+ * @example
45
+ * ```html
46
+ * <moni-text-field
47
+ * label="Dirección de correo"
48
+ * type="email"
49
+ * name="email"
50
+ * icon="mail"
51
+ * variant="outlined"
52
+ * helper="Nunca compartiremos tu correo."
53
+ * ></moni-text-field>
54
+ *
55
+ * <moni-text-field
56
+ * label="Monto"
57
+ * type="number"
58
+ * prefix="$"
59
+ * error
60
+ * error-text="El valor debe ser positivo"
61
+ * ></moni-text-field>
62
+ * ```
63
+ *
64
+ * @csspart field - El contenedor div `.field` exterior.
65
+ * @csspart input - El elemento `<input>` nativo.
66
+ * @csspart label - El elemento `<label>` flotante.
67
+ * @csspart helper - El elemento `<output>` de ayuda.
68
+ * @csspart error-output - El elemento `<output>` de error.
69
+ */
70
+ @customElement("moni-text-field")
71
+ export class MoniTextField extends MoniElement {
72
+ static formAssociated = true;
73
+ /** Constructor original para acceder también a la API estática completa de Inputmask. */
74
+ static readonly Inputmask = Inputmask;
75
+ private _internals: ElementInternals;
76
+
77
+ constructor() {
78
+ super();
79
+ this._internals = this.attachInternals();
80
+ }
81
+
82
+ /**
83
+ * El nombre del input, enviado con los datos del formulario.
84
+ * @type {string}
85
+ */
86
+ @property({ reflect: true }) name = "";
87
+
88
+ /**
89
+ * El texto de la etiqueta flotante.
90
+ * @type {string}
91
+ */
92
+ @property({ reflect: true }) label = "";
93
+
94
+ /**
95
+ * Variante visual del campo de texto.
96
+ * @type {'filled' | 'outlined' | 'underlined'}
97
+ * @default 'filled'
98
+ */
99
+ @property({ reflect: true })
100
+ variant: "filled" | "outlined" | "underlined" = "filled";
101
+
102
+ /**
103
+ * Define las dimensiones del campo de texto.
104
+ * @type {'small' | 'medium' | 'large' | 'extra'}
105
+ * @default 'medium'
106
+ */
107
+ @property({ reflect: true })
108
+ size: "small" | "medium" | "large" | "extra" = "medium";
109
+
110
+ /**
111
+ * Forma del radio del borde (border-radius) del campo.
112
+ * @type {'round' | 'small-round' | 'no-round'}
113
+ * @default 'no-round'
114
+ */
115
+ @property({ reflect: true })
116
+ shape: "round" | "small-round" | "no-round" = "no-round";
117
+
118
+ /**
119
+ * El tipo de input HTML nativo.
120
+ * @type {'text' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local' | 'month' | 'week' | 'color'}
121
+ * @default 'text'
122
+ */
123
+ @property({ reflect: true })
124
+ type:
125
+ | "text"
126
+ | "password"
127
+ | "email"
128
+ | "number"
129
+ | "tel"
130
+ | "url"
131
+ | "search"
132
+ | "date"
133
+ | "time"
134
+ | "datetime-local"
135
+ | "month"
136
+ | "week"
137
+ | "color" = "text";
138
+
139
+ /**
140
+ * Nombre del icono inicial (leading) (Material Symbols).
141
+ * @type {string}
142
+ */
143
+ @property({ reflect: true }) icon = "";
144
+
145
+ /**
146
+ * Nombre del icono final (trailing) (Material Symbols).
147
+ * @type {string}
148
+ */
149
+ @property({ reflect: true, attribute: "trailing-icon" }) trailingIcon = "";
150
+
151
+ /**
152
+ * Prefijo de texto corto mostrado antes del valor del input.
153
+ * @type {string}
154
+ */
155
+ @property({ reflect: true }) prefix = "";
156
+
157
+ /**
158
+ * Sufijo de texto corto mostrado después del valor del input.
159
+ * @type {string}
160
+ */
161
+ @property({ reflect: true }) suffix = "";
162
+
163
+ /** Icono del botón interactivo ubicado al final del campo. */
164
+ @property({ reflect: true, attribute: "suffix-button-icon" })
165
+ suffixButtonIcon = "";
166
+
167
+ /** Etiqueta accesible del botón suffix. */
168
+ @property({ reflect: true, attribute: "suffix-button-label" })
169
+ suffixButtonLabel = "Acción del campo";
170
+
171
+ @state() private _hasSuffixSlot = false;
172
+
173
+ /**
174
+ * Texto de ayuda mostrado debajo del campo.
175
+ * @type {string}
176
+ */
177
+ @property({ reflect: true }) helper = "";
178
+
179
+ /**
180
+ * Texto de error mostrado debajo del campo cuando `error` es true.
181
+ * Sobrescribe el texto de ayuda.
182
+ * @type {string}
183
+ */
184
+ @property({ reflect: true, attribute: "error-text" }) errorText = "";
185
+
186
+ /**
187
+ * Si es true, establece el campo en un estado de error.
188
+ * @type {boolean}
189
+ */
190
+ @property({ type: Boolean, reflect: true }) error = false;
191
+
192
+ /**
193
+ * Si es true, muestra un indicador de carga (progreso lineal/circular) al final.
194
+ * @type {boolean}
195
+ */
196
+ @property({ type: Boolean, reflect: true }) loading = false;
197
+
198
+ /**
199
+ * Deshabilita el campo de texto.
200
+ * @type {boolean}
201
+ */
202
+ @property({ type: Boolean, reflect: true }) disabled = false;
203
+
204
+ /**
205
+ * El valor actual del input.
206
+ * @type {string}
207
+ */
208
+ @property({ reflect: true }) value = "";
209
+
210
+ /**
211
+ * Texto de marcador de posición (placeholder) mostrado cuando el input está vacío y la etiqueta es flotante.
212
+ * @type {string}
213
+ */
214
+ @property({ reflect: true }) placeholder = "";
215
+
216
+ /*
217
+ * Restricciones nativas.
218
+ *
219
+ * Antes ninguna de estas llegaba al `<input>` interno: el componente sólo
220
+ * reenviaba `type`, `placeholder`, `disabled`, `value` y `name`. Eso dejaba
221
+ * fuera el autocompletado de contraseñas, el teclado correcto en móvil, el
222
+ * límite de caracteres y toda la validación de restricciones, así que cada
223
+ * consumidor que necesitaba una de ellas tenía que renunciar al componente y
224
+ * usar un input nativo.
225
+ *
226
+ * Se reflejan al host además de reenviarse para que `[required]` y compañía
227
+ * puedan usarse como selectores CSS desde fuera.
228
+ */
229
+
230
+ /** Marca el campo como obligatorio y lo integra en la validación del formulario. */
231
+ @property({ type: Boolean, reflect: true }) required = false;
232
+
233
+ /** Impide editar el valor sin sacarlo del envío ni atenuarlo como `disabled`. */
234
+ @property({ type: Boolean, reflect: true }) readonly = false;
235
+
236
+ /** Pista de autocompletado del navegador, por ejemplo `email` o `current-password`. */
237
+ @property({ reflect: true }) autocomplete = "";
238
+
239
+ /** Teclado virtual a mostrar en móvil, por ejemplo `numeric` o `tel`. */
240
+ @property({ reflect: true }) inputmode = "";
241
+
242
+ /** Máximo de caracteres aceptados. */
243
+ @property({ type: Number, reflect: true }) maxlength: number | null = null;
244
+
245
+ /** Mínimo de caracteres aceptados. */
246
+ @property({ type: Number, reflect: true }) minlength: number | null = null;
247
+
248
+ /** Valor mínimo para `number` y los tipos de fecha y hora. */
249
+ @property({ reflect: true }) min = "";
250
+
251
+ /** Valor máximo para `number` y los tipos de fecha y hora. */
252
+ @property({ reflect: true }) max = "";
253
+
254
+ /** Incremento para `number` y los tipos de fecha y hora. */
255
+ @property({ reflect: true }) step = "";
256
+
257
+ /** Expresión regular que debe cumplir el valor. */
258
+ @property({ reflect: true }) pattern = "";
259
+
260
+ /**
261
+ * Patrón Inputmask. Admite opcionales `[]`, grupos `()`, alternadores `|`,
262
+ * cuantificadores `{n,m}` y los tokens `9`, `a`, `*` y `K` (RUT).
263
+ * @example `99.999.999-K`
264
+ */
265
+ @property({ reflect: true }) mask = "";
266
+
267
+ /** Alias integrado de Inputmask, por ejemplo `email`, `datetime`, `numeric`, `currency` o `ip`. */
268
+ @property({ reflect: true, attribute: "mask-alias" }) maskAlias = "";
269
+
270
+ /** Opciones avanzadas de Inputmask. También acepta JSON mediante el atributo `mask-options`. */
271
+ @property({
272
+ attribute: "mask-options",
273
+ converter: {
274
+ fromAttribute: (value: string | null) => {
275
+ if (!value) return {};
276
+ try {
277
+ return JSON.parse(value) as InputmaskOptions;
278
+ } catch {
279
+ return {};
280
+ }
281
+ },
282
+ toAttribute: (value: InputmaskOptions) => JSON.stringify(value ?? {}),
283
+ },
284
+ })
285
+ maskOptions: InputmaskOptions = {};
286
+
287
+ /** Valor actual sin los caracteres literales definidos por `mask`. */
288
+ get unmaskedValue(): string {
289
+ return this._maskInstance?.unmaskedvalue() ?? this.value;
290
+ }
291
+
292
+ @query("input") private _input!: HTMLInputElement;
293
+ private _maskInstance?: InputmaskInstance;
294
+
295
+ /** Instancia original de Inputmask aplicada al input nativo. */
296
+ get inputmaskInstance(): InputmaskInstance | undefined {
297
+ return this._maskInstance;
298
+ }
299
+
300
+ /** Lee o actualiza opciones de la instancia activa. */
301
+ maskOption(name: string): unknown;
302
+ maskOption(
303
+ options: InputmaskOptions,
304
+ noRemask?: boolean,
305
+ ): InputmaskInstance | undefined;
306
+ maskOption(
307
+ nameOrOptions: string | InputmaskOptions,
308
+ noRemask = false,
309
+ ): unknown {
310
+ if (!this._maskInstance) return undefined;
311
+ return typeof nameOrOptions === "string"
312
+ ? this._maskInstance.option(nameOrOptions)
313
+ : this._maskInstance.option(nameOrOptions, noRemask);
314
+ }
315
+
316
+ getEmptyMask(): string {
317
+ return this._maskInstance?.getemptymask() ?? "";
318
+ }
319
+
320
+ hasMaskedValue(): boolean {
321
+ return this._maskInstance?.hasMaskedValue() ?? false;
322
+ }
323
+
324
+ isMaskComplete(): boolean {
325
+ return this._maskInstance?.isComplete() ?? true;
326
+ }
327
+
328
+ isMaskValid(value?: string): boolean {
329
+ return this._maskInstance?.isValid(value) ?? true;
330
+ }
331
+
332
+ getMaskMetadata(): unknown {
333
+ return this._maskInstance?.getmetadata();
334
+ }
335
+
336
+ formatWithMask(
337
+ value: string,
338
+ metadata = false,
339
+ ): string | { value: string; metadata: unknown } {
340
+ return this._maskInstance?.format(value, metadata) ?? value;
341
+ }
342
+
343
+ setMaskedValue(value: string): void {
344
+ if (!this._maskInstance) {
345
+ this.value = value;
346
+ return;
347
+ }
348
+ this._maskInstance.setValue(value);
349
+ this.value = this._input.value;
350
+ this._internals?.setFormValue?.(this.value);
351
+ }
352
+
353
+ removeMask(): void {
354
+ this._maskInstance?.remove();
355
+ this._maskInstance = undefined;
356
+ }
357
+
358
+ private _configureMask() {
359
+ this._maskInstance?.remove();
360
+ this._maskInstance = undefined;
361
+ if (!this._input || (!this.mask && !this.maskAlias)) return;
362
+
363
+ const options: InputmaskOptions = {
364
+ placeholder: "",
365
+ ...this.maskOptions,
366
+ ...(this.mask ? { mask: this.mask } : {}),
367
+ definitions: {
368
+ ...(this.maskOptions.definitions ?? {}),
369
+ K: { validator: "[0-9Kk]", casing: "upper" },
370
+ },
371
+ };
372
+ const inputmask = this.maskAlias
373
+ ? new Inputmask(this.maskAlias, options)
374
+ : new Inputmask(options);
375
+ this._maskInstance = inputmask.mask(this._input);
376
+ }
377
+
378
+ /**
379
+ * Sincroniza imperativamente las propiedades DOM del `<input>` nativo después de cada actualización reactiva.
380
+ *
381
+ * Las vinculaciones `?value` y `?disabled` de Lit establecen *atributos* HTML, no *propiedades* DOM.
382
+ * Para un `<input>`, el atributo `value` solo establece el valor *inicial*; la propiedad DOM
383
+ * `input.value` refleja el valor actual tecleado por el usuario. Después de una actualización reactiva que
384
+ * cambia `this.value` (ej. reinicio programático), debemos escribir directamente a la propiedad
385
+ * DOM para sobrescribir lo que el usuario ha tecleado. Esto también es crítico para los consumidores
386
+ * de React y Vue que mutan la propiedad del Custom Element directamente, omitiendo el flujo
387
+ * de atributos de Lit.
388
+ *
389
+ * @param changed - Mapa de los nombres de propiedades a sus valores anteriores que causaron esta actualización.
390
+ */
391
+ override updated(changed: Map<string, unknown>) {
392
+ if (this._input) {
393
+ if (
394
+ changed.has("mask") ||
395
+ changed.has("maskAlias") ||
396
+ changed.has("maskOptions")
397
+ ) {
398
+ this._configureMask();
399
+ }
400
+ if (changed.has("value")) {
401
+ this._input.value = this.value;
402
+ if (this._maskInstance && this.value !== this._input.value) {
403
+ this.value = this._input.value;
404
+ }
405
+ this._internals?.setFormValue?.(this.value);
406
+ }
407
+ if (changed.has("disabled")) this._input.disabled = this.disabled;
408
+ }
409
+ this._syncValidity();
410
+ }
411
+
412
+ /**
413
+ * Copia la validez del `<input>` interno al host.
414
+ *
415
+ * Reenviar `required` o `pattern` no basta: el navegador valida el
416
+ * formulario mirando los controles asociados a él, y el que aquí participa
417
+ * es el host, no el input del Shadow DOM. Sin este puente el campo se
418
+ * pintaría como obligatorio pero el envío no se detendría.
419
+ *
420
+ * El tercer argumento ancla el globo de validación al input real, para que
421
+ * el navegador tenga dónde mostrarlo y a dónde mover el foco.
422
+ */
423
+ /**
424
+ * API de validación equivalente a la de un control nativo.
425
+ *
426
+ * Los elementos form-associated participan en la validación del formulario,
427
+ * pero no reciben estos miembros automáticamente: hay que exponerlos para que
428
+ * un `moni-text-field` se pueda interrogar igual que un `<input>`.
429
+ */
430
+ get validity(): ValidityState {
431
+ return this._internals.validity;
432
+ }
433
+
434
+ get validationMessage(): string {
435
+ return this._internals.validationMessage;
436
+ }
437
+
438
+ checkValidity(): boolean {
439
+ return this._internals.checkValidity();
440
+ }
441
+
442
+ reportValidity(): boolean {
443
+ return this._internals.reportValidity();
444
+ }
445
+
446
+ private _syncValidity() {
447
+ if (!this._input || !this._internals?.setValidity) return;
448
+ this._internals.setValidity(
449
+ this._input.validity,
450
+ this._input.validationMessage,
451
+ this._input,
452
+ );
453
+ }
454
+
455
+ private _handleInput(e: Event) {
456
+ const target = e.target as HTMLInputElement;
457
+ this.value = target.value;
458
+ // Al teclear, `value` no siempre cambia de referencia (máscaras), así que
459
+ // la validez se refresca aquí y no sólo desde `updated()`.
460
+ this._syncValidity();
461
+ emitMoniEvent(this, "moni-input", {
462
+ detail: {
463
+ value: this.value,
464
+ unmaskedValue: this.unmaskedValue,
465
+ originalEvent: e,
466
+ },
467
+ });
468
+ }
469
+
470
+ private _handleChange(e: Event) {
471
+ const target = e.target as HTMLInputElement;
472
+ this.value = target.value;
473
+ emitMoniEvent(this, "moni-change", {
474
+ detail: {
475
+ value: this.value,
476
+ unmaskedValue: this.unmaskedValue,
477
+ originalEvent: e,
478
+ },
479
+ });
480
+ }
481
+
482
+ override disconnectedCallback() {
483
+ this._maskInstance?.remove();
484
+ this._maskInstance = undefined;
485
+ super.disconnectedCallback();
486
+ }
487
+
488
+ private _handleSuffixClick(event: Event) {
489
+ if (this.disabled) return;
490
+ this.dispatchEvent(
491
+ new CustomEvent("suffix-click", {
492
+ detail: { originalEvent: event },
493
+ bubbles: true,
494
+ composed: true,
495
+ }),
496
+ );
497
+ }
498
+
499
+ private _handleSuffixSlotChange(event: Event) {
500
+ const slot = event.currentTarget as HTMLSlotElement;
501
+ this._hasSuffixSlot = slot.assignedElements({ flatten: true }).length > 0;
502
+ }
503
+
504
+ static override styles = [
505
+ sharedStyles,
506
+ fieldStyles,
507
+ css`
508
+ .suffix-action {
509
+ position: absolute;
510
+ inset-inline-end: 0.5rem;
511
+ inset-block-start: calc(var(--_middle) - 1.25rem);
512
+ z-index: 3;
513
+ display: grid;
514
+ place-items: center;
515
+ inline-size: 2.5rem;
516
+ block-size: 2.5rem;
517
+ pointer-events: auto;
518
+ }
519
+ .suffix-action[hidden] {
520
+ display: none;
521
+ }
522
+ .suffix-button {
523
+ all: unset;
524
+ box-sizing: border-box;
525
+ display: grid;
526
+ place-items: center;
527
+ inline-size: 2.5rem;
528
+ block-size: 2.5rem;
529
+ border-radius: 50%;
530
+ color: var(--on-surface-variant);
531
+ cursor: pointer;
532
+ transition:
533
+ background-color 150ms ease,
534
+ transform 150ms ease;
535
+ }
536
+ .suffix-button:hover {
537
+ background: color-mix(in srgb, currentColor 8%, transparent);
538
+ }
539
+ .suffix-button:active {
540
+ background: color-mix(in srgb, currentColor 12%, transparent);
541
+ transform: scale(0.92);
542
+ }
543
+ .suffix-button:focus-visible {
544
+ outline: 0.125rem solid var(--primary);
545
+ outline-offset: 0.125rem;
546
+ }
547
+ .suffix-button:disabled {
548
+ opacity: 0.38;
549
+ cursor: default;
550
+ }
551
+ .suffix-button moni-icon {
552
+ --moni-icon-size: 1.5rem;
553
+ }
554
+ ::slotted([slot="suffix"]) {
555
+ pointer-events: auto;
556
+ }
557
+ .field.suffix-action-field > input {
558
+ padding-inline-end: 3.5rem;
559
+ }
560
+ .field.suffix-action-field > input:focus {
561
+ padding-inline-end: 3.4375rem;
562
+ }
563
+ `,
564
+ ];
565
+
566
+ /**
567
+ * Renderiza el campo de texto con animación de etiqueta flotante, iconos iniciales/finales, y texto de ayuda.
568
+ *
569
+ * **Composición de `fieldClasses`:**
570
+ * - `field` — siempre presente; diseño base del campo.
571
+ * - `label` — habilita el comportamiento de la etiqueta flotante vía CSS.
572
+ * - `fill` / `border` — variantes filled y outlined; underlined no agrega ninguna.
573
+ * - `small` / `large` / `extra` — modificador de tamaño.
574
+ * - `prefix` — desplaza la etiqueta cuando hay un icono inicial (leading) presente.
575
+ * - `suffix` — reserva espacio final (trailing) para el icono/texto de sufijo.
576
+ * - `invalid` — aplica colores de error cuando `error=true` o falla la validación nativa.
577
+ *
578
+ * **Flotación de la etiqueta (Label float):**
579
+ * `isActive` es true cuando `value` no está vacío o se establece un `placeholder`.
580
+ * Los selectores CSS `.field.label input:focus ~ label`, `.field.label.active label`
581
+ * flotan la etiqueta a la parte superior del borde del campo. Este componente
582
+ * establece `isActive` para evitar que la etiqueta colapse cuando un valor está preestablecido.
583
+ *
584
+ * **Zonas inicial / final (Leading / trailing):**
585
+ * `hasLeading` y `hasTrailing` añaden condicionalmente las clases CSS `prefix`/`suffix`
586
+ * y renderizan el icono o los elementos de texto de prefijo/sufijo.
587
+ */
588
+ override render() {
589
+ const hasLeading = Boolean(this.icon) || Boolean(this.prefix);
590
+ const hasTrailing =
591
+ Boolean(this.trailingIcon) ||
592
+ Boolean(this.suffix) ||
593
+ Boolean(this.suffixButtonIcon) ||
594
+ this._hasSuffixSlot;
595
+ const isActive = Boolean(this.value) || Boolean(this.placeholder);
596
+ const fieldClasses = {
597
+ field: true,
598
+ label: Boolean(this.label),
599
+ fill: this.variant === "filled",
600
+ border: this.variant === "outlined",
601
+ small: this.size === "small",
602
+ large: this.size === "large",
603
+ extra: this.size === "extra",
604
+ prefix: hasLeading,
605
+ suffix: hasTrailing,
606
+ "suffix-action-field":
607
+ Boolean(this.suffixButtonIcon) || this._hasSuffixSlot,
608
+ invalid: this.error,
609
+ round: this.shape === "round" && this.variant !== "underlined",
610
+ "small-round":
611
+ this.shape === "small-round" && this.variant !== "underlined",
612
+ square: this.shape === "no-round",
613
+ };
614
+ const placeholder = this.placeholder || (this.label ? " " : "");
615
+
616
+ const leading = this.icon
617
+ ? html`<i class="leading-icon" part="leading-icon"
618
+ ><moni-icon name="${this.icon}"></moni-icon
619
+ ></i>`
620
+ : this.prefix
621
+ ? html`<span class="leading-icon" part="prefix">${this.prefix}</span>`
622
+ : nothing;
623
+
624
+ const trailing = this.loading
625
+ ? html`<i class="trailing-icon" part="trailing-icon"
626
+ ><moni-progress
627
+ variant="circular"
628
+ indeterminate
629
+ size="small"
630
+ style="inline-size: 1.25rem; block-size: 1.25rem; color: currentColor;"
631
+ ></moni-progress
632
+ ></i>`
633
+ : this.suffixButtonIcon
634
+ ? html`<span class="suffix-action" part="suffix-action">
635
+ <button
636
+ type="button"
637
+ class="suffix-button"
638
+ part="suffix-button"
639
+ aria-label=${this.suffixButtonLabel}
640
+ ?disabled=${this.disabled}
641
+ @click=${this._handleSuffixClick}
642
+ >
643
+ <moni-icon name=${this.suffixButtonIcon}></moni-icon>
644
+ </button>
645
+ </span>`
646
+ : this.trailingIcon
647
+ ? html`<i class="trailing-icon" part="trailing-icon"
648
+ ><moni-icon name="${this.trailingIcon}"></moni-icon
649
+ ></i>`
650
+ : this.suffix
651
+ ? html`<span class="trailing-icon" part="suffix"
652
+ >${this.suffix}</span
653
+ >`
654
+ : nothing;
655
+
656
+ return html`<div class=${classMap(fieldClasses)} part="field">
657
+ ${leading}
658
+ <input
659
+ id="input"
660
+ part="input"
661
+ type=${this.type}
662
+ placeholder=${placeholder}
663
+ ?disabled=${this.disabled}
664
+ ?required=${this.required}
665
+ ?readonly=${this.readonly}
666
+ autocomplete=${ifDefined(this.autocomplete || undefined)}
667
+ inputmode=${ifDefined(this.inputmode || undefined)}
668
+ maxlength=${ifDefined(this.maxlength ?? undefined)}
669
+ minlength=${ifDefined(this.minlength ?? undefined)}
670
+ min=${ifDefined(this.min || undefined)}
671
+ max=${ifDefined(this.max || undefined)}
672
+ step=${ifDefined(this.step || undefined)}
673
+ pattern=${ifDefined(this.pattern || undefined)}
674
+ .value=${this.value}
675
+ name=${ifDefined(this.name || undefined)}
676
+ class=${isActive ? "active" : ""}
677
+ @input=${this._handleInput}
678
+ @change=${this._handleChange}
679
+ />
680
+ ${
681
+ this.label
682
+ ? html`<label
683
+ for="input"
684
+ part="label"
685
+ class=${classMap({ active: isActive })}
686
+ >${this.label}</label
687
+ >`
688
+ : nothing
689
+ }
690
+ ${trailing}
691
+ <span
692
+ class="suffix-action"
693
+ part="suffix-action"
694
+ ?hidden=${Boolean(this.suffixButtonIcon) || !this._hasSuffixSlot}
695
+ >
696
+ <slot name="suffix" @slotchange=${this._handleSuffixSlotChange}></slot>
697
+ </span>
698
+ ${
699
+ this.error
700
+ ? html`<output part="helper" class="invalid"
701
+ >${this.errorText || this.helper}</output
702
+ >`
703
+ : this.helper
704
+ ? html`<output part="helper">${this.helper}</output>`
705
+ : nothing
706
+ }
707
+ </div>`;
708
+ }
709
+ }
710
+
711
+ declare global {
712
+ interface HTMLElementTagNameMap {
713
+ "moni-text-field": MoniTextField;
714
+ }
715
+ }
716
+
717
+ export default MoniTextField;