@moni-labs/moni-ui 0.4.13 → 0.4.14

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 (96) hide show
  1. package/custom-elements.json +91 -81
  2. package/dist/browser/moni-ui.iife.min.js +1537 -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 +2703 -2481
  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 +1 -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-morph-modal.d.ts +10 -0
  67. package/dist/components/moni-morph-modal.d.ts.map +1 -1
  68. package/dist/components/moni-morph-modal.js +323 -217
  69. package/dist/components/moni-select.d.ts +16 -16
  70. package/dist/components/moni-select.d.ts.map +1 -1
  71. package/dist/components/moni-select.js +797 -727
  72. package/dist/components/moni-text-field.d.ts +15 -15
  73. package/dist/components/moni-text-field.d.ts.map +1 -1
  74. package/dist/components/moni-text-field.js +224 -180
  75. package/dist/components/moni-textarea.d.ts +13 -13
  76. package/dist/components/moni-textarea.d.ts.map +1 -1
  77. package/dist/components/moni-textarea.js +127 -122
  78. package/docs/api.json +97 -88
  79. package/docs/assets/moni-morph-modal/autoHeight--false.png +0 -0
  80. package/docs/assets/moni-morph-modal/autoHeight--true.png +0 -0
  81. package/docs/components/morph-modal.md +16 -7
  82. package/docs/components/select.md +74 -74
  83. package/docs/components/text-field.md +75 -66
  84. package/docs/components/textarea.md +51 -51
  85. package/docs/llms.txt +3 -3
  86. package/package.json +1 -1
  87. package/src/components/_base/field-styles.ts +723 -637
  88. package/src/components/moni-morph-modal.test.ts +490 -413
  89. package/src/components/moni-morph-modal.ts +2274 -2162
  90. package/src/components/moni-select.test.ts +489 -466
  91. package/src/components/moni-select.ts +1963 -1815
  92. package/src/components/moni-text-field.test.ts +366 -342
  93. package/src/components/moni-text-field.ts +717 -645
  94. package/src/components/moni-textarea.test.ts +201 -197
  95. package/src/components/moni-textarea.ts +476 -460
  96. package/dist/cdn/chunks/if-defined-DdzvcoI7.js +0 -1
@@ -1,1815 +1,1963 @@
1
- /**
2
- * @file components/moni-select.ts
3
- * @package @moni-labs/moni-ui
4
- * @license MIT
5
- * @contributors Moni Labs & Contributors
6
- */
7
-
8
- import { html, css, nothing } from 'lit';
9
- import { customElement, property, state, query } from 'lit/decorators.js';
10
- import { ifDefined } from 'lit/directives/if-defined.js';
11
- import { classMap } from 'lit/directives/class-map.js';
12
- import { MoniElement, sharedStyles, fieldStyles } from './_base/index.js';
13
- import { emitMoniEvent } from '../utils/event-emitter.js';
14
- import './moni-icon.js';
15
- import './moni-progress.js';
16
- import './moni-select-option.js';
17
-
18
- /**
19
- * Representación interna de una opción individual dentro del menú desplegable del select.
20
- *
21
- * @internal
22
- */
23
- interface OptionNode {
24
- /** Propiedad discriminante que identifica este nodo como una opción. */
25
- type: 'option';
26
- /** El valor enviado cuando se selecciona esta opción. */
27
- value: string;
28
- /** La etiqueta mostrada en la lista desplegable. */
29
- label: string;
30
- /** Cuando es `true`, la opción no es interactiva y aparece atenuada (gris). */
31
- disabled?: boolean;
32
- /** Referencia al elemento original `<moni-select-option>` de la ranura (slot), si lo hay. */
33
- element?: HTMLElement;
34
- /** Nombre de grupo opcional para organizar opciones bajo un `<moni-select-group>`. */
35
- group?: string;
36
- }
37
-
38
- /**
39
- * Representación interna de un grupo de opciones dentro del menú desplegable del select.
40
- *
41
- * @internal
42
- */
43
- interface GroupNode {
44
- /** Propiedad discriminante que identifica este nodo como un grupo. */
45
- type: 'group';
46
- /** La etiqueta de encabezado del grupo mostrada sobre las opciones del grupo. */
47
- label: string;
48
- /** Las opciones anidadas dentro de este grupo. */
49
- children: DropdownNode[];
50
- }
51
-
52
- /** Tipo de unión para cualquier nodo en el árbol del menú desplegable. @internal */
53
- type DropdownNode = OptionNode | GroupNode;
54
-
55
- /**
56
- * Componente Material Design 3 Select (Menú Desplegable).
57
- *
58
- * Un menú desplegable select personalizado completo con capacidad de búsqueda, grupos de opciones,
59
- * navegación por teclado, animación y un modo de cajón/hoja (drawer/sheet) móvil opcional.
60
- * Reemplaza el elemento nativo `<select>` con una alternativa completamente estilizada y accesible
61
- * que cumple con M3.
62
- *
63
- * **Referencia a la especificación M3:** `m3-docs/components/menus/specs.md` (menús desplegables)
64
- *
65
- * **Resumen de características:**
66
- * - Variantes Filled y Outlined que coinciden con los estilos de campo de texto de M3.
67
- * - Etiqueta flotante (floating label) con la animación estándar de etiqueta flotante de estilos de campo.
68
- * - Modo de búsqueda: el atributo `searchable` agrega un campo de entrada de filtro en línea.
69
- * - Grupos de opciones: elementos `<moni-select-group>` en el slot para opciones jerárquicas.
70
- * - Cajón móvil: el atributo `drawer` abre las opciones en un `<moni-bottom-sheet>`
71
- * en lugar de un popup desplegable, ideal para interfaces táctiles (touch UIs).
72
- * - Navegación por teclado: Teclas de flecha, Enter, Escape y Tab según combobox de ARIA.
73
- * - Estado de carga: `loading` muestra un progreso circular indeterminado.
74
- * - Soporte multi-valor: `multiple` permite selección múltiple.
75
- *
76
- * **Fuentes de opciones:**
77
- * Las opciones se pueden proporcionar de dos maneras:
78
- * 1. **Elementos `<moni-select-option>` en la ranura (slot)** (por defecto, recomendado para SSR).
79
- * 2. **Propiedad `options`** — un array de `DropdownNode[]` para un control completamente programático.
80
- *
81
- * **Vinculación de valores (Value binding):**
82
- * La propiedad `value` contiene la cadena de valor de la opción actualmente seleccionada.
83
- * Para selección múltiple, `values` contiene `string[]`. Al cambiar, se dispara un
84
- * evento compuesto `'moni-change'`.
85
- *
86
- * @fires moni-change - Burbujea y está compuesto. Se dispara cuando el valor seleccionado cambia.
87
- * Lee `element.value` (o `element.values` para `multiple`).
88
- * @fires moni-select-open - Se dispara al abrirse para que los demás selects cierren sus menús.
89
- *
90
- * @example
91
- * ```html
92
- * <moni-select label="País" name="country" variant="outlined">
93
- * <moni-select-option value="us">Estados Unidos</moni-select-option>
94
- * <moni-select-option value="gb">Reino Unido</moni-select-option>
95
- * <moni-select-option value="de">Alemania</moni-select-option>
96
- * </moni-select>
97
- *
98
- * <!-- Select con búsqueda -->
99
- * <moni-select label="Idioma" searchable>
100
- * <moni-select-option value="ts">TypeScript</moni-select-option>
101
- * <moni-select-option value="py">Python</moni-select-option>
102
- * </moni-select>
103
- * ```
104
- *
105
- * @slot default - Hijos `<moni-select-option>` o `<moni-select-group>`.
106
- *
107
- * @csspart field - El contenedor div exterior `.field`.
108
- * @csspart dropdown - El contenedor flotante de la lista de opciones.
109
- */
110
- @customElement('moni-select')
111
- export class MoniSelect extends MoniElement {
112
- static formAssociated = true;
113
- private _internals: ElementInternals;
114
-
115
- constructor() {
116
- super();
117
- this._internals = this.attachInternals();
118
- }
119
-
120
- /**
121
- * Nombre del campo select, usado para el envío del formulario.
122
- * @type {string}
123
- */
124
- @property({ reflect: true }) name = '';
125
-
126
- /**
127
- * Texto de la etiqueta flotante.
128
- * @type {string}
129
- */
130
- @property({ reflect: true }) label = '';
131
-
132
- /**
133
- * Variante visual del campo select.
134
- * @type {'filled' | 'outlined'}
135
- * @default 'filled'
136
- */
137
- @property({ reflect: true }) variant: 'filled' | 'outlined' = 'filled';
138
-
139
- /**
140
- * Define las dimensiones del campo select.
141
- * @type {'small' | 'medium' | 'large' | 'extra'}
142
- * @default 'medium'
143
- */
144
- @property({ reflect: true })
145
- size: 'small' | 'medium' | 'large' | 'extra' = 'medium';
146
-
147
- /**
148
- * Forma (radio de borde) del campo.
149
- * @type {'round' | 'small-round' | 'square' | 'no-round'}
150
- * @default 'no-round'
151
- */
152
- @property({ reflect: true })
153
- shape: 'round' | 'small-round' | 'square' | 'no-round' = 'no-round';
154
-
155
- /**
156
- * Deshabilita el campo select.
157
- * @type {boolean}
158
- */
159
- @property({ type: Boolean, reflect: true }) disabled = false;
160
-
161
- /**
162
- * Obliga a elegir una opción antes de enviar el formulario.
163
- *
164
- * El `<input>` visible es de sólo lectura, así que la validación nativa no
165
- * se aplica sobre él: la restricción se declara en el host con
166
- * `ElementInternals.setValidity`, que es lo que mira el formulario.
167
- */
168
- @property({ type: Boolean, reflect: true }) required = false;
169
-
170
- /**
171
- * Si es verdadero, muestra un indicador de carga (progreso lineal).
172
- * @type {boolean}
173
- */
174
- @property({ type: Boolean, reflect: true }) loading = false;
175
-
176
- /**
177
- * Texto de ayuda mostrado debajo del campo.
178
- * @type {string}
179
- */
180
- @property({ reflect: true }) helper = '';
181
-
182
- /**
183
- * Texto de error mostrado debajo del campo cuando `error` es true.
184
- * Reemplaza al texto de ayuda.
185
- * @type {string}
186
- */
187
- @property({ reflect: true, attribute: 'error-text' }) errorText = '';
188
-
189
- /**
190
- * Si es verdadero, establece el campo en un estado de error.
191
- * @type {boolean}
192
- */
193
- @property({ type: Boolean, reflect: true }) error = false;
194
-
195
- /**
196
- * El valor actual del select.
197
- * @type {string}
198
- */
199
- @property({ reflect: true }) value = '';
200
-
201
- /**
202
- * Nombre del icono principal (leading icon, Material Symbols).
203
- * @type {string}
204
- */
205
- @property({ reflect: true }) icon = '';
206
-
207
- /**
208
- * Nombre del icono final (trailing icon, Material Symbols) que indica el estado del menú desplegable.
209
- * @type {string}
210
- * @default 'arrow_drop_down'
211
- */
212
- @property({ reflect: true, attribute: 'trailing-icon' }) trailingIcon =
213
- 'arrow_drop_down';
214
-
215
- /**
216
- * Habilita un campo de búsqueda en la parte superior del menú desplegable para filtrar opciones.
217
- * @type {boolean}
218
- */
219
- @property({ type: Boolean, reflect: true }) searchable = false;
220
-
221
- /**
222
- * Muestra un botón de limpieza cuando se selecciona un valor para restablecer fácilmente el campo.
223
- * @type {boolean}
224
- */
225
- @property({ type: Boolean, reflect: true }) clearable = false;
226
-
227
- /**
228
- * Renderiza las opciones como una hoja inferior (bottom sheet, ideal para dispositivos móviles) en lugar de un menú desplegable.
229
- * @type {boolean}
230
- */
231
- @property({ type: Boolean, reflect: true }) sheet = false;
232
-
233
- /**
234
- * Texto de marcador de posición (placeholder) mostrado cuando no hay ningún valor seleccionado.
235
- * @type {string}
236
- */
237
- @property({ reflect: true }) placeholder = '';
238
-
239
- /**
240
- * Estrategia de posicionamiento para el menú desplegable.
241
- *
242
- * - `'absolute'`: permanece en el contexto de posicionamiento del select.
243
- * - `'fixed'`: usa coordenadas del viewport, pero conserva su contexto de apilamiento.
244
- * - `'body'`: usa la capa superior nativa (Popover API) para escapar de `overflow`,
245
- * diálogos, bottom sheets y contextos de apilamiento.
246
- *
247
- * @type {'absolute' | 'fixed' | 'body'}
248
- * @default 'absolute'
249
- */
250
- @property({ reflect: true }) positioning: 'absolute' | 'fixed' | 'body' = 'absolute';
251
-
252
- /**
253
- * Ubicación preferida del menú desplegable con respecto al disparador (trigger).
254
- * @type {'top' | 'bottom' | 'left' | 'right' | 'auto'}
255
- * @default 'auto'
256
- */
257
- @property({ reflect: true }) placement: 'top' | 'bottom' | 'left' | 'right' | 'auto' = 'auto';
258
-
259
- /**
260
- * Restricción de ancho del menú desplegable.
261
- * - `'trigger'`: Coincide con el ancho del campo de entrada.
262
- * - `'auto'`: Coincide con el ancho del contenido del menú desplegable.
263
- * - O cualquier valor CSS de ancho válido (ej. '200px').
264
- * @type {string}
265
- * @default 'trigger'
266
- */
267
- @property({ reflect: true, attribute: 'dropdown-width' }) dropdownWidth = 'trigger';
268
-
269
- /**
270
- * Límite manual opcional para la altura del menú.
271
- *
272
- * Acepta cualquier longitud CSS válida, por ejemplo `240px`, `40vh` o
273
- * `calc(100vh - 8rem)`. El valor nunca supera el límite automático calculado
274
- * según el viewport, la posición del campo y el tope predeterminado de 75vh.
275
- * Una cadena vacía conserva el cálculo automático.
276
- *
277
- * @default ''
278
- */
279
- @property({ reflect: true, attribute: 'dropdown-max-height' }) dropdownMaxHeight = '';
280
-
281
- @state() private _open = false;
282
- @state() private _searchQuery = '';
283
- @state() private _actualPlacement: 'top' | 'bottom' | 'left' | 'right' = 'bottom';
284
- @state() private _parsedOptions: DropdownNode[] = [];
285
- @state() private _activeIndex = -1;
286
- @state() private _menuStyle = '';
287
- @state() private _menuMaxHeight = '75vh';
288
- @state() private _useInlineCategories = false;
289
- @state() private _drilldownPath: GroupNode[] = [];
290
-
291
- @query('slot') private _slot!: HTMLSlotElement;
292
- @query('input') private _input!: HTMLInputElement;
293
-
294
- /**
295
- * Declara la validez del host según `required`.
296
- *
297
- * No se puede delegar en el `<input>` interno porque es `readonly`, y un
298
- * campo de sólo lectura queda excluido de la validación de restricciones. El
299
- * ancla sigue siendo ese input para que el navegador tenga dónde mostrar el
300
- * mensaje y a dónde llevar el foco.
301
- */
302
- /**
303
- * API de validación equivalente a la de un control nativo.
304
- *
305
- * Los elementos form-associated participan en la validación del formulario,
306
- * pero no reciben estos miembros automáticamente: hay que exponerlos para que
307
- * un `moni-select` se pueda interrogar igual que un `<input>`.
308
- */
309
- get validity(): ValidityState {
310
- return this._internals.validity;
311
- }
312
-
313
- get validationMessage(): string {
314
- return this._internals.validationMessage;
315
- }
316
-
317
- checkValidity(): boolean {
318
- return this._internals.checkValidity();
319
- }
320
-
321
- reportValidity(): boolean {
322
- return this._internals.reportValidity();
323
- }
324
-
325
- private _syncValidity() {
326
- if (!this._internals?.setValidity) return;
327
- if (this.required && !this.value) {
328
- this._internals.setValidity(
329
- { valueMissing: true },
330
- 'Selecciona una opción.',
331
- this._input ?? undefined
332
- );
333
- return;
334
- }
335
- this._internals.setValidity({});
336
- }
337
- @query('.dropdown-menu') private _menu?: HTMLElement;
338
-
339
- static override styles = [
340
- sharedStyles,
341
- fieldStyles,
342
- css`
343
- :host {
344
- position: relative;
345
- display: block;
346
- }
347
-
348
- .field {
349
- cursor: pointer;
350
- }
351
-
352
- input[type='text'] {
353
- cursor: pointer;
354
- width: 100%;
355
- }
356
-
357
- input[type='text'][readonly] {
358
- caret-color: transparent;
359
- user-select: none;
360
- }
361
-
362
- /* Dropdown menu overlay */
363
- .dropdown-menu {
364
- position: absolute;
365
- top: 100%;
366
- left: 0;
367
- right: 0;
368
- z-index: 100;
369
- background-color: var(--surface-container);
370
- box-shadow: var(--elevate3);
371
- border-radius: 0.5rem;
372
- margin-top: 4px;
373
- padding: 4px 0;
374
- list-style: none;
375
- opacity: 0;
376
- visibility: hidden;
377
- transform: scale(0.95) translateY(-8px);
378
- transform-origin: top;
379
- transition:
380
- opacity var(--speed3) cubic-bezier(0.2, 0.8, 0.2, 1),
381
- transform var(--speed3) cubic-bezier(0.34, 1.56, 0.64, 1),
382
- visibility var(--speed3);
383
- }
384
-
385
- .dropdown-menu.open {
386
- opacity: 1;
387
- visibility: visible;
388
- transform: scale(1) translateY(0);
389
- }
390
-
391
- /* positioning="body" promotes the menu to the browser's top layer.
392
- Reset the Popover UA box while retaining Shadow DOM styles. */
393
- .dropdown-menu[popover] {
394
- margin: 0;
395
- border: 0;
396
- }
397
-
398
- .dropdown-menu.placement-top {
399
- top: auto;
400
- bottom: 100%;
401
- margin-top: 0;
402
- margin-bottom: 4px;
403
- transform-origin: bottom;
404
- transform: scale(0.95) translateY(8px);
405
- }
406
-
407
- .dropdown-menu.placement-top.open {
408
- transform: scale(1) translateY(0);
409
- }
410
-
411
- .dropdown-menu.placement-left {
412
- top: 0;
413
- left: auto;
414
- right: 100%;
415
- margin-top: 0;
416
- margin-right: 4px;
417
- transform-origin: right center;
418
- transform: scale(0.95) translateX(8px);
419
- }
420
-
421
- .dropdown-menu.placement-left.open {
422
- transform: scale(1) translateX(0);
423
- }
424
-
425
- .dropdown-menu.placement-right {
426
- top: 0;
427
- left: 100%;
428
- right: auto;
429
- margin-top: 0;
430
- margin-left: 4px;
431
- transform-origin: left center;
432
- transform: scale(0.95) translateX(-8px);
433
- }
434
-
435
- .dropdown-menu.placement-right.open {
436
- transform: scale(1) translateX(0);
437
- }
438
-
439
- .dropdown-menu.open.scrollable {
440
- overflow-x: hidden;
441
- overflow-y: auto;
442
- overscroll-behavior: contain;
443
- }
444
-
445
- /* Desktop category flyouts need visible overflow. If the root list no
446
- longer fits, the component switches to the inline drilldown layout. */
447
- .dropdown-menu.open:not(.scrollable) {
448
- overflow: visible;
449
- }
450
-
451
- /* Bottom sheet drawer styles */
452
- .sheet-backdrop {
453
- position: fixed;
454
- inset: 0;
455
- z-index: 1000;
456
- background-color: rgba(0, 0, 0, 0.4);
457
- opacity: 0;
458
- visibility: hidden;
459
- transition: opacity var(--speed3) cubic-bezier(0.2, 0.8, 0.2, 1), visibility var(--speed3);
460
- display: flex;
461
- flex-direction: column;
462
- justify-content: flex-end;
463
- }
464
-
465
- .sheet-backdrop.open {
466
- opacity: 1;
467
- visibility: visible;
468
- }
469
-
470
- .sheet-drawer {
471
- background-color: var(--surface-container);
472
- border-radius: 1.75rem 1.75rem 0 0;
473
- box-shadow: var(--elevate3);
474
- max-height: 70vh;
475
- display: flex;
476
- flex-direction: column;
477
- transform: translateY(100%);
478
- transition: transform var(--speed3) cubic-bezier(0.16, 1, 0.3, 1);
479
- padding: 16px 0;
480
- }
481
-
482
- .sheet-drawer.open {
483
- transform: translateY(0);
484
- }
485
-
486
- .sheet-handle {
487
- width: 40px;
488
- height: 4px;
489
- background-color: var(--outline-variant);
490
- border-radius: 2px;
491
- margin: 0 auto 16px;
492
- }
493
-
494
- .sheet-title {
495
- padding: 0 24px 8px;
496
- font-size: 1.125rem;
497
- font-weight: 700;
498
- color: var(--on-surface);
499
- }
500
-
501
- .sheet-search-wrapper {
502
- padding: 0 16px 12px;
503
- }
504
-
505
- .sheet-search-input {
506
- width: 100%;
507
- box-sizing: border-box;
508
- padding: 10px 16px;
509
- border: 1px solid var(--outline);
510
- border-radius: 9999px;
511
- background: var(--surface-container-high);
512
- color: var(--on-surface);
513
- font-size: 1rem;
514
- outline: none;
515
- }
516
-
517
- .sheet-search-input:focus {
518
- border-color: var(--primary);
519
- }
520
-
521
- .sheet-options-list {
522
- list-style: none;
523
- padding: 0;
524
- margin: 0;
525
- overflow-y: auto;
526
- flex: 1;
527
- }
528
-
529
- /* Animated Drilldown Slide Layout */
530
- .drilldown-wrapper {
531
- width: 100%;
532
- overflow: hidden;
533
- position: relative;
534
- transition: height var(--speed3) cubic-bezier(0.2, 0.8, 0.2, 1);
535
- }
536
-
537
- .drilldown-track {
538
- display: flex;
539
- width: 200%;
540
- transition: transform var(--speed3) cubic-bezier(0.2, 0.8, 0.2, 1);
541
- }
542
-
543
- .drilldown-track.slide-active {
544
- transform: translateX(-50%);
545
- }
546
-
547
- .drilldown-panel {
548
- width: 50%;
549
- box-sizing: border-box;
550
- list-style: none;
551
- padding: 0;
552
- margin: 0;
553
- flex-shrink: 0;
554
- }
555
-
556
- .drilldown-panel.panel-active {
557
- max-height: none;
558
- }
559
-
560
- .drilldown-panel.panel-inactive {
561
- max-height: 0;
562
- overflow: hidden;
563
- }
564
-
565
- /* Group / Subcategory header (Browsing mode - acts as a hover trigger for submenus) */
566
- .group-header {
567
- padding: 8px 16px;
568
- font-size: 0.875rem;
569
- font-weight: 500;
570
- color: var(--on-surface);
571
- cursor: pointer;
572
- display: flex;
573
- align-items: center;
574
- position: relative;
575
- user-select: none;
576
- transition: background-color var(--speed2) ease, color var(--speed2) ease;
577
- }
578
-
579
- .group-header:hover,
580
- .group-header.active {
581
- background-color: var(--surface-container-high);
582
- color: var(--primary);
583
- }
584
-
585
- /* Inline header (Searching mode & Sheet mode - flat layout) */
586
- .group-header-flat {
587
- padding: 6px 16px;
588
- font-size: 0.75rem;
589
- font-weight: 700;
590
- text-transform: uppercase;
591
- color: var(--primary);
592
- letter-spacing: 0.05em;
593
- background-color: var(--surface-container-low);
594
- user-select: none;
595
- }
596
-
597
- /* Hierarchical sub-menu with premium flyout animation */
598
- .submenu {
599
- display: block;
600
- opacity: 0;
601
- visibility: hidden;
602
- position: absolute;
603
- top: 0;
604
- /* Intermediate flyouts must keep overflow visible so deeper
605
- subcategories are never clipped. Oversized trees switch to the
606
- scrollable inline drilldown layout before rendering. */
607
- overflow: visible;
608
- background-color: var(--surface-container-highest);
609
- box-shadow: var(--elevate3);
610
- border-radius: 0.5rem;
611
- padding: 4px 0;
612
- list-style: none;
613
- min-width: 160px;
614
- z-index: 110;
615
- transition:
616
- opacity var(--speed2) cubic-bezier(0.2, 0.8, 0.2, 1),
617
- transform var(--speed2) cubic-bezier(0.34, 1.56, 0.64, 1),
618
- visibility var(--speed2);
619
- }
620
-
621
- .submenu.open-right {
622
- left: 100%;
623
- margin-left: 2px;
624
- transform: scale(0.9) translateX(-10px);
625
- transform-origin: top left;
626
- }
627
-
628
- .submenu.open-left {
629
- right: 100%;
630
- margin-right: 2px;
631
- transform: scale(0.9) translateX(10px);
632
- transform-origin: top right;
633
- }
634
-
635
- .group-header:hover > .submenu.active,
636
- .group-header.active > .submenu.active {
637
- opacity: 1;
638
- visibility: visible;
639
- transform: scale(1) translateX(0);
640
- }
641
-
642
- .option-item {
643
- cursor: pointer;
644
- padding: 8px 16px;
645
- font-size: 0.875rem;
646
- color: var(--on-surface);
647
- display: flex;
648
- align-items: center;
649
- transition: background-color var(--speed2) ease;
650
- }
651
-
652
- .option-item:hover,
653
- .option-item.active-nav {
654
- background-color: var(--surface-container-high);
655
- }
656
-
657
- .option-item.selected {
658
- background-color: var(--tertiary-container);
659
- color: var(--on-tertiary-container);
660
- font-weight: 500;
661
- }
662
-
663
- .option-item.disabled {
664
- opacity: 0.5;
665
- pointer-events: none;
666
- }
667
-
668
- .no-options {
669
- padding: 12px 16px;
670
- font-size: 0.875rem;
671
- color: var(--outline);
672
- text-align: center;
673
- }
674
- `
675
- ];
676
-
677
- /**
678
- * Hook del ciclo de vida (Lit).
679
- * Vincula event listeners globales necesarios para la lógica del dropdown.
680
- * Se usa `capture: true` en el evento de scroll para poder reaccionar
681
- * al desplazamiento de cualquier contenedor padre y reposicionar el menú a tiempo.
682
- */
683
- override connectedCallback() {
684
- super.connectedCallback();
685
- document.addEventListener('click', this._handleOutsideClick);
686
- document.addEventListener('moni-select-open', this._handlePeerOpen as EventListener);
687
- window.addEventListener('scroll', this._handleScroll, { capture: true });
688
- window.addEventListener('resize', this._handleResize);
689
- window.visualViewport?.addEventListener('scroll', this._handleScroll);
690
- window.visualViewport?.addEventListener('resize', this._handleResize);
691
- }
692
-
693
- /**
694
- * Hook del ciclo de vida (Lit).
695
- * Desmonta rigurosamente todos los listeners globales para prevenir memory leaks
696
- * o llamadas accidentales cuando el select ya fue destruido del DOM.
697
- */
698
- override disconnectedCallback() {
699
- this._hideBodyPopover();
700
- super.disconnectedCallback();
701
- document.removeEventListener('click', this._handleOutsideClick);
702
- document.removeEventListener('moni-select-open', this._handlePeerOpen as EventListener);
703
- window.removeEventListener('scroll', this._handleScroll, { capture: true });
704
- window.removeEventListener('resize', this._handleResize);
705
- window.visualViewport?.removeEventListener('scroll', this._handleScroll);
706
- window.visualViewport?.removeEventListener('resize', this._handleResize);
707
- }
708
-
709
- /**
710
- * Hook de actualización reactiva (Lit).
711
- * Verifica si alguna propiedad relacionada con el tamaño del contenido (como la búsqueda,
712
- * la ruta de anidación o el estado abierto) ha cambiado, para forzar un recálculo
713
- * de la altura animada del contenedor `drilldown-wrapper`.
714
- */
715
- override updated(changedProperties: Map<string | number | symbol, unknown>) {
716
- super.updated(changedProperties);
717
- if (changedProperties.has('value')) {
718
- this._internals?.setFormValue?.(this.value);
719
- }
720
- this._syncValidity();
721
- if (
722
- changedProperties.has('_open') ||
723
- changedProperties.has('_drilldownPath') ||
724
- changedProperties.has('_searchQuery') ||
725
- changedProperties.has('_useInlineCategories') ||
726
- changedProperties.has('_parsedOptions')
727
- ) {
728
- this._updateWrapperHeight();
729
- }
730
- if (
731
- this._open &&
732
- (changedProperties.has('positioning') ||
733
- changedProperties.has('placement') ||
734
- changedProperties.has('dropdownWidth') ||
735
- changedProperties.has('dropdownMaxHeight'))
736
- ) {
737
- this._updateMenuPosition();
738
- }
739
- if (
740
- changedProperties.has('_open') ||
741
- changedProperties.has('positioning') ||
742
- changedProperties.has('sheet')
743
- ) {
744
- this._syncBodyPopover();
745
- }
746
- }
747
-
748
- /**
749
- * Lógica de animación de altura (Height Animation).
750
- * Cuando se navega entre categorías anidadas (`_drilldownPath`), las listas hijas pueden
751
- * tener diferentes tamaños. Este método lee el `scrollHeight` del panel activo y lo aplica
752
- * imperativamente al `wrapper` para disparar una transición CSS suave en la altura del dropdown.
753
- */
754
- private _updateWrapperHeight() {
755
- const wrapper = this.shadowRoot?.querySelector('.drilldown-wrapper') as HTMLElement;
756
- if (!wrapper) return;
757
-
758
- if (!this._open || this.sheet) {
759
- wrapper.style.height = '';
760
- return;
761
- }
762
-
763
- // Wait for Lit render and styles to apply
764
- requestAnimationFrame(() => {
765
- const activePanel = this.shadowRoot?.querySelector('.drilldown-panel.panel-active') as HTMLElement;
766
- if (activePanel) {
767
- wrapper.style.height = `${activePanel.scrollHeight}px`;
768
- } else {
769
- wrapper.style.height = '';
770
- }
771
- });
772
- }
773
-
774
- /**
775
- * Cierra el Dropdown cuando el usuario hace clic fuera de las dimensiones del componente.
776
- * Utiliza `e.composedPath()` para poder atravesar barreras de Shadow DOM si el select
777
- * está dentro de un contenedor web-component externo.
778
- */
779
- private _handleOutsideClick = (e: MouseEvent) => {
780
- if (this._open && !e.composedPath().includes(this)) {
781
- this._closeDropdown();
782
- }
783
- };
784
-
785
- /** Cierra este menú cuando otro `<moni-select>` anuncia que va a abrirse. */
786
- private _handlePeerOpen = (e: Event) => {
787
- const source = (e as CustomEvent<{ item?: MoniSelect }>).detail?.item;
788
- if (source && source !== this && this._open) {
789
- this._closeDropdown();
790
- }
791
- };
792
-
793
- /**
794
- * Sincroniza el modo `positioning="body"` con la Popover API. Un popover
795
- * permanece en este Shadow DOM para conservar estilos y listeners, pero el
796
- * navegador lo promueve a la capa superior, fuera de cualquier `overflow`.
797
- */
798
- private _syncBodyPopover() {
799
- const menu = this._menu;
800
- if (!menu || typeof menu.showPopover !== 'function') return;
801
-
802
- const shouldBeOpen = this._open && !this.sheet && this.positioning === 'body';
803
- try {
804
- const isOpen = menu.matches(':popover-open');
805
- if (shouldBeOpen && !isOpen) {
806
- menu.showPopover();
807
- } else if (!shouldBeOpen && isOpen) {
808
- menu.hidePopover();
809
- }
810
- } catch {
811
- /* Browsers without a complete Popover implementation use fixed fallback. */
812
- }
813
- }
814
-
815
- private _hideBodyPopover() {
816
- const menu = this._menu;
817
- if (!menu || typeof menu.hidePopover !== 'function') return;
818
- try {
819
- if (menu.matches(':popover-open')) menu.hidePopover();
820
- } catch {
821
- /* No-op during teardown or in partial DOM implementations. */
822
- }
823
- }
824
-
825
- /**
826
- * Motor de Cálculo de Colisiones de la Interfaz (Collision Detection).
827
- *
828
- * @logic
829
- * 1. Calcula las coordenadas espaciales (`getBoundingClientRect`) del select base.
830
- * 2. Compara el espacio disponible (`spaceAbove`, `spaceBelow`, `spaceRight`, `spaceLeft`)
831
- * respecto al viewport (`window.innerHeight`, `window.innerWidth`).
832
- * 3. Si el dropdown no cabe en la posición preferida (`placement`), revierte lógicamente
833
- * la posición (ej: si `bottom` desborda, lo abre hacia `top`).
834
- * 4. Actualiza `_actualPlacement` con la dirección segura calculada.
835
- */
836
- private _getViewportMetrics() {
837
- const viewport = window.visualViewport;
838
- const top = viewport?.offsetTop ?? 0;
839
- const left = viewport?.offsetLeft ?? 0;
840
- const height = viewport?.height ?? window.innerHeight;
841
- const width = viewport?.width ?? window.innerWidth;
842
- return { top, left, height, width, bottom: top + height, right: left + width };
843
- }
844
-
845
- /**
846
- * Calcula el espacio vertical en una dirección. Además del espacio físico,
847
- * descuenta la posición del trigger del presupuesto de 75% del viewport:
848
- * `75vh - offset del campo`, tal como se espera para campos a media pantalla.
849
- */
850
- private _getDirectionalAvailableHeight(
851
- direction: 'top' | 'bottom',
852
- rect: DOMRectReadOnly
853
- ): number {
854
- const viewport = this._getViewportMetrics();
855
- const gap = 8;
856
- const viewportCap = viewport.height * 0.75;
857
-
858
- if (direction === 'bottom') {
859
- const physicalSpace = viewport.bottom - rect.bottom - gap;
860
- const triggerOffset = rect.bottom - viewport.top + gap;
861
- return Math.max(0, Math.floor(Math.min(
862
- viewportCap,
863
- physicalSpace,
864
- viewportCap - triggerOffset
865
- )));
866
- }
867
-
868
- const physicalSpace = rect.top - viewport.top - gap;
869
- const triggerOffset = viewport.bottom - rect.top + gap;
870
- return Math.max(0, Math.floor(Math.min(
871
- viewportCap,
872
- physicalSpace,
873
- viewportCap - triggerOffset
874
- )));
875
- }
876
-
877
- private _normalizeManualMaxHeight(): string {
878
- const raw = this.dropdownMaxHeight.trim();
879
- if (!raw) return '';
880
- const normalized = /^\d+(?:\.\d+)?$/.test(raw) ? `${raw}px` : raw;
881
- if (
882
- typeof CSS !== 'undefined' &&
883
- typeof CSS.supports === 'function' &&
884
- !CSS.supports('max-height', normalized)
885
- ) {
886
- return '';
887
- }
888
- return normalized;
889
- }
890
-
891
- /** Convierte las unidades manuales comunes a px para el motor de colisiones. */
892
- private _resolveManualMaxHeightPx(value: string, viewportHeight: number): number | null {
893
- const match = value.match(/^(\d+(?:\.\d+)?)(px|vh|dvh|svh|lvh|%|rem)$/i);
894
- if (!match) return null;
895
- const amount = Number(match[1]);
896
- const unit = match[2].toLowerCase();
897
- if (unit === 'px') return amount;
898
- if (unit === 'rem') {
899
- const rootSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
900
- return amount * rootSize;
901
- }
902
- return viewportHeight * (amount / 100);
903
- }
904
-
905
- private _getMenuHeightConstraint(
906
- rect: DOMRectReadOnly,
907
- placement = this._actualPlacement
908
- ): { css: string; px: number } {
909
- const viewport = this._getViewportMetrics();
910
- const automatic = placement === 'top' || placement === 'bottom'
911
- ? this._getDirectionalAvailableHeight(placement, rect)
912
- : Math.max(0, Math.floor(Math.min(viewport.height * 0.75, viewport.height - 16)));
913
- const manualCss = this._normalizeManualMaxHeight();
914
- const manualPx = manualCss
915
- ? this._resolveManualMaxHeightPx(manualCss, viewport.height)
916
- : null;
917
- const px = manualPx === null ? automatic : Math.min(automatic, manualPx);
918
-
919
- return {
920
- css: manualCss ? `min(${automatic}px, ${manualCss})` : `${automatic}px`,
921
- px: Math.max(0, px)
922
- };
923
- }
924
-
925
- private _estimateDesiredMenuHeight(): number {
926
- const viewport = this._getViewportMetrics();
927
- const itemCount = this._searchQuery
928
- ? this._getFilteredOptions().length
929
- : this._parsedOptions.length;
930
- let desired = Math.min(viewport.height * 0.75, Math.max(48, itemCount * 40 + 8));
931
- const manual = this._normalizeManualMaxHeight();
932
- const manualPx = manual
933
- ? this._resolveManualMaxHeightPx(manual, viewport.height)
934
- : null;
935
- if (manualPx !== null) desired = Math.min(desired, manualPx);
936
- return desired;
937
- }
938
-
939
- /** Profundidad máxima de flyouts necesaria para representar el árbol. */
940
- private _getGroupDepth(nodes: DropdownNode[]): number {
941
- return nodes.reduce((depth, node) => {
942
- if (node.type === 'option') return depth;
943
- return Math.max(depth, 1 + this._getGroupDepth(node.children));
944
- }, 0);
945
- }
946
-
947
- /** Detecta cualquier nivel cuya lista directa necesitaría scroll propio. */
948
- private _hasOversizedGroupPanel(nodes: DropdownNode[], maxHeight: number): boolean {
949
- return nodes.some(node => {
950
- if (node.type === 'option') return false;
951
- const panelHeight = node.children.length * 40 + 8;
952
- return panelHeight > maxHeight || this._hasOversizedGroupPanel(node.children, maxHeight);
953
- });
954
- }
955
-
956
- /**
957
- * Mide el espacio horizontal real para flyouts. En modo local también
958
- * intersecta ancestros con overflow para no diseñar submenús fuera de una
959
- * región que terminaría recortándolos. `body` ignora esos ancestros porque
960
- * se renderiza en la top layer.
961
- */
962
- private _getAvailableFlyoutWidth(rect: DOMRectReadOnly): number {
963
- const viewport = this._getViewportMetrics();
964
- let boundaryLeft = viewport.left;
965
- let boundaryRight = viewport.right;
966
-
967
- if (this.positioning !== 'body') {
968
- let current: Node | null = this;
969
- while (current) {
970
- const parent: Node | null = current.parentNode instanceof ShadowRoot
971
- ? current.parentNode.host
972
- : current.parentNode;
973
- if (parent instanceof HTMLElement) {
974
- const styles = getComputedStyle(parent);
975
- if (/(auto|scroll|hidden|clip)/.test(styles.overflowX)) {
976
- const parentRect = parent.getBoundingClientRect();
977
- boundaryLeft = Math.max(boundaryLeft, parentRect.left);
978
- boundaryRight = Math.min(boundaryRight, parentRect.right);
979
- }
980
- }
981
- current = parent;
982
- }
983
- }
984
-
985
- return Math.max(
986
- 0,
987
- Math.max(boundaryRight - rect.right, rect.left - boundaryLeft)
988
- );
989
- }
990
-
991
- private _determineActualPlacement() {
992
- if (this.sheet) {
993
- this._actualPlacement = 'bottom';
994
- return;
995
- }
996
-
997
- const rect = this.getBoundingClientRect();
998
- const viewport = this._getViewportMetrics();
999
- const spaceBelow = this._getDirectionalAvailableHeight('bottom', rect);
1000
- const spaceAbove = this._getDirectionalAvailableHeight('top', rect);
1001
- const spaceRight = viewport.right - rect.right;
1002
- const spaceLeft = rect.left - viewport.left;
1003
- const menuHeight = this._estimateDesiredMenuHeight();
1004
- const menuWidth = 180;
1005
-
1006
- let preferred = this.placement;
1007
- if (preferred === 'auto') {
1008
- preferred = spaceBelow >= menuHeight
1009
- ? 'bottom'
1010
- : (spaceAbove > spaceBelow ? 'top' : 'bottom');
1011
- }
1012
-
1013
- if (preferred === 'top') {
1014
- this._actualPlacement = spaceAbove < menuHeight && spaceBelow > spaceAbove
1015
- ? 'bottom'
1016
- : 'top';
1017
- } else if (preferred === 'bottom') {
1018
- this._actualPlacement = spaceBelow < menuHeight && spaceAbove > spaceBelow
1019
- ? 'top'
1020
- : 'bottom';
1021
- } else if (preferred === 'left') {
1022
- this._actualPlacement = spaceLeft < menuWidth && spaceRight > spaceLeft
1023
- ? 'right'
1024
- : 'left';
1025
- } else if (preferred === 'right') {
1026
- this._actualPlacement = spaceRight < menuWidth && spaceLeft > spaceRight
1027
- ? 'left'
1028
- : 'right';
1029
- }
1030
- }
1031
-
1032
- /**
1033
- * Handler de Scroll global.
1034
- * Fuerza un recálculo de la posición y dimensiones del dropdown si el usuario
1035
- * hace scroll en la página mientras el select permanece abierto.
1036
- */
1037
- private _handleScroll = () => {
1038
- if (this._open) {
1039
- this._determineActualPlacement();
1040
- this._updateMenuPosition();
1041
- }
1042
- };
1043
-
1044
- /**
1045
- * Handler de Resize global.
1046
- * Asegura que el popover del select no quede fuera del viewport cuando
1047
- * se cambia el tamaño de la ventana o se rota la pantalla en dispositivos móviles.
1048
- */
1049
- private _handleResize = () => {
1050
- if (this._open) {
1051
- this._determineActualPlacement();
1052
- this._updateMenuPosition();
1053
- }
1054
- };
1055
-
1056
- /**
1057
- * Aplica las coordenadas y dimensiones absolutas o fijas al menú desplegable.
1058
- * Este método es fundamental cuando `positioning === 'fixed'`, ya que el dropdown
1059
- * se saca del flujo normal del documento para evitar `overflow: hidden` de los padres.
1060
- * Mapea el `width` para coincidir opcionalmente con el gatillo (`dropdownWidth="trigger"`).
1061
- */
1062
- private _updateMenuPosition() {
1063
- if (!this._open || this.sheet) return;
1064
-
1065
- this._determineActualPlacement();
1066
- const rect = this.getBoundingClientRect();
1067
- const viewport = this._getViewportMetrics();
1068
- const constraint = this._getMenuHeightConstraint(rect);
1069
- this._menuMaxHeight = constraint.css;
1070
-
1071
- const hasGroups = this._parsedOptions.some(node => node.type === 'group');
1072
- const estimatedRootHeight = this._parsedOptions.length * 40 + 8;
1073
- const groupDepth = this._getGroupDepth(this._parsedOptions);
1074
- const requiredFlyoutWidth = groupDepth * 160;
1075
- const availableFlyoutWidth = this._getAvailableFlyoutWidth(rect);
1076
- const lacksFlyoutSpace =
1077
- viewport.width < 600 ||
1078
- (hasGroups && availableFlyoutWidth < requiredFlyoutWidth);
1079
- const needsVerticalDrilldown = hasGroups && (
1080
- estimatedRootHeight > constraint.px ||
1081
- this._hasOversizedGroupPanel(this._parsedOptions, constraint.px)
1082
- );
1083
- this._useInlineCategories = lacksFlyoutSpace || needsVerticalDrilldown;
1084
-
1085
- if (this.positioning !== 'fixed' && this.positioning !== 'body') {
1086
- this._menuStyle = '';
1087
- return;
1088
- }
1089
-
1090
- let widthStyle = '';
1091
- if (this.dropdownWidth === 'trigger') {
1092
- widthStyle = `width: ${rect.width}px;`;
1093
- } else if (this.dropdownWidth === 'auto') {
1094
- widthStyle = 'width: auto; min-width: 160px; max-width: 320px;';
1095
- } else {
1096
- widthStyle = `width: ${this.dropdownWidth};`;
1097
- }
1098
-
1099
- if (this._actualPlacement === 'top') {
1100
- this._menuStyle = `
1101
- position: fixed;
1102
- bottom: ${window.innerHeight - rect.top}px;
1103
- left: ${rect.left}px;
1104
- ${widthStyle}
1105
- top: auto;
1106
- right: auto;
1107
- `;
1108
- } else if (this._actualPlacement === 'bottom') {
1109
- this._menuStyle = `
1110
- position: fixed;
1111
- top: ${rect.bottom}px;
1112
- left: ${rect.left}px;
1113
- ${widthStyle}
1114
- bottom: auto;
1115
- right: auto;
1116
- `;
1117
- } else {
1118
- const sideTop = Math.max(
1119
- viewport.top + 8,
1120
- Math.min(rect.top, viewport.bottom - constraint.px - 8)
1121
- );
1122
- if (this._actualPlacement === 'left') {
1123
- this._menuStyle = `
1124
- position: fixed;
1125
- top: ${sideTop}px;
1126
- right: ${window.innerWidth - rect.left + 4}px;
1127
- ${widthStyle}
1128
- left: auto;
1129
- bottom: auto;
1130
- `;
1131
- } else {
1132
- this._menuStyle = `
1133
- position: fixed;
1134
- top: ${sideTop}px;
1135
- left: ${rect.right + 4}px;
1136
- ${widthStyle}
1137
- right: auto;
1138
- bottom: auto;
1139
- `;
1140
- }
1141
- }
1142
- }
1143
-
1144
- /**
1145
- * Devuelve las coordenadas, el ancho y la restricción dinámica de altura del
1146
- * menú. `body` comparte coordenadas fixed, pero se pinta en la capa superior.
1147
- */
1148
- private _getMenuStyle() {
1149
- const constraintStyle = `max-height: ${this._menuMaxHeight}; --_select-menu-max-height: ${this._menuMaxHeight};`;
1150
- if (this.positioning === 'fixed' || this.positioning === 'body') {
1151
- return `${this._menuStyle} ${constraintStyle}`;
1152
- }
1153
-
1154
- if (this.dropdownWidth === 'auto') {
1155
- return `width: max-content; min-width: 160px; max-width: 320px; ${constraintStyle}`;
1156
- } else if (this.dropdownWidth !== 'trigger') {
1157
- return `width: ${this.dropdownWidth}; ${constraintStyle}`;
1158
- }
1159
- return constraintStyle;
1160
- }
1161
-
1162
- /**
1163
- * Parseador del Shadow DOM (Slot Change Handler).
1164
- *
1165
- * @logic
1166
- * 1. Cuando los hijos proyectados cambian (ej. se agregan nuevos `<option>`),
1167
- * lee iterativamente todos los nodos del Light DOM.
1168
- * 2. Si encuentra `<optgroup>` o `<moni-select-option group="A/B">`, construye dinámicamente
1169
- * un árbol estructural de `GroupNode` anidados.
1170
- * 3. Si encuentra opciones, las inyecta en el grupo correspondiente del árbol.
1171
- * 4. Almacena el árbol resultante en `_parsedOptions` para que el componente
1172
- * pueda renderizar sus propios submenús nativos independientemente del markup inicial.
1173
- */
1174
- private _onSlotChange() {
1175
- if (!this._slot) return;
1176
- const assigned = this._slot.assignedElements({ flatten: true });
1177
-
1178
- const tree: DropdownNode[] = [];
1179
-
1180
- const findOrCreateGroup = (nodes: DropdownNode[], label: string): GroupNode => {
1181
- let group = nodes.find(n => n.type === 'group' && n.label === label) as GroupNode;
1182
- if (!group) {
1183
- group = { type: 'group', label, children: [] };
1184
- nodes.push(group);
1185
- }
1186
- return group;
1187
- };
1188
-
1189
- const insertOption = (targetTree: DropdownNode[], opt: OptionNode, groupPath: string[]) => {
1190
- let currentLevel = targetTree;
1191
- if (groupPath.length > 0) {
1192
- opt.group = groupPath.join(' / ');
1193
- }
1194
- groupPath.forEach(part => {
1195
- const group = findOrCreateGroup(currentLevel, part);
1196
- currentLevel = group.children;
1197
- });
1198
- currentLevel.push(opt);
1199
- };
1200
-
1201
- const parseElement = (el: Element, parentGroupPath: string[]) => {
1202
- const tag = el.tagName.toLowerCase();
1203
- if (tag === 'option' || tag === 'moni-select-option') {
1204
- const opt: OptionNode = {
1205
- type: 'option',
1206
- value: el.getAttribute('value') || '',
1207
- label: el.textContent?.trim() || el.getAttribute('label') || el.getAttribute('value') || '',
1208
- disabled: el.hasAttribute('disabled'),
1209
- element: el as HTMLElement
1210
- };
1211
- const groupAttr = el.getAttribute('group');
1212
- const localPath = groupAttr ? groupAttr.split('/').map(s => s.trim()).filter(Boolean) : [];
1213
- const finalPath = [...parentGroupPath, ...localPath];
1214
- insertOption(tree, opt, finalPath);
1215
- } else if (tag === 'optgroup') {
1216
- const label = el.getAttribute('label') || '';
1217
- const localPath = [...parentGroupPath, label];
1218
- Array.from(el.children).forEach(child => parseElement(child, localPath));
1219
- }
1220
- };
1221
-
1222
- assigned.forEach(el => parseElement(el, []));
1223
- this._parsedOptions = tree;
1224
- this.requestUpdate();
1225
- }
1226
-
1227
- /**
1228
- * Alterna el estado de apertura/cierre del dropdown, previniendo
1229
- * la acción si el componente está deshabilitado.
1230
- */
1231
- private _toggleDropdown(e?: Event) {
1232
- if (e) {
1233
- e.stopPropagation();
1234
- }
1235
- if (this.disabled) return;
1236
- if (this._open) {
1237
- this._closeDropdown();
1238
- } else {
1239
- this._openDropdown();
1240
- }
1241
- }
1242
-
1243
- /**
1244
- * Manejador de clics en el campo de texto (Trigger).
1245
- * Si el componente es `searchable` (permite escribir para buscar), un clic
1246
- * sólo abrirá el menú (no lo cerrará si ya estaba abierto, para permitir al usuario seguir escribiendo).
1247
- */
1248
- private _onInputClick(e: MouseEvent) {
1249
- if (this.disabled) return;
1250
- if (this.searchable && !this.sheet) {
1251
- if (!this._open) {
1252
- this._openDropdown();
1253
- }
1254
- } else {
1255
- this._toggleDropdown(e);
1256
- }
1257
- }
1258
-
1259
- /**
1260
- * Despliega el menú del select y coordina la accesibilidad, posicionamiento y foco inicial.
1261
- */
1262
- private _openDropdown() {
1263
- if (this.disabled || this._open) return;
1264
-
1265
- this._open = true;
1266
- this._activeIndex = -1;
1267
- this._drilldownPath = [];
1268
- // La búsqueda siempre comienza limpia, aunque exista un valor seleccionado.
1269
- this._searchQuery = '';
1270
-
1271
- // El evento global permite que cualquier otro select abierto se cierre,
1272
- // incluso cuando la apertura vino del teclado o del icono trailing.
1273
- emitMoniEvent(this, 'moni-select-open', { detail: { item: this } });
1274
-
1275
- this._updateMenuPosition();
1276
-
1277
- if (this.searchable) {
1278
- // Esperamos un tick para que el input o sheet esté renderizado antes de enfocarlo.
1279
- setTimeout(() => {
1280
- if (!this._open) return;
1281
- if (this._input && !this.sheet) {
1282
- this._input.focus();
1283
- } else if (this.sheet) {
1284
- const sheetInput = this.shadowRoot?.querySelector('.sheet-search-input') as HTMLInputElement;
1285
- sheetInput?.focus();
1286
- }
1287
- }, 50);
1288
- }
1289
- }
1290
-
1291
- /**
1292
- * Cierra el dropdown y purga la consulta de búsqueda (`_searchQuery`)
1293
- * para que la próxima vez que se abra, la lista muestre todas las opciones por defecto.
1294
- */
1295
- private _closeDropdown() {
1296
- if (!this._open) return;
1297
- this._open = false;
1298
- this._searchQuery = '';
1299
- this._hideBodyPopover();
1300
- }
1301
-
1302
- private _findOptionByValue(value: string): OptionNode | undefined {
1303
- return this._getFlatOptions(this._parsedOptions).find(opt => opt.value === value);
1304
- }
1305
-
1306
- /**
1307
- * Evento de input (tecleo) en la barra de búsqueda.
1308
- *
1309
- * @logic
1310
- * 1. Actualiza `_searchQuery` y fuerza la apertura del dropdown.
1311
- * 2. Si es `clearable` y el usuario borró todo, emite eventos vacíos.
1312
- * 3. Busca coincidencias exactas en el texto. Si el usuario teclea exactamente el
1313
- * label de una opción existente, la selecciona automáticamente (auto-completado nativo).
1314
- */
1315
- private _onSearchInput(e: Event) {
1316
- const query = (e.target as HTMLInputElement).value;
1317
- if (!this._open) this._openDropdown();
1318
- this._searchQuery = query;
1319
- this._activeIndex = -1;
1320
-
1321
- if (this.clearable && query.trim() === '') {
1322
- this.value = '';
1323
- emitMoniEvent(this, 'moni-change', { detail: { value: this.value, originalEvent: e } });
1324
- return;
1325
- }
1326
-
1327
- const flat = this._getFlatOptions(this._parsedOptions);
1328
- const exactMatch = flat.find(
1329
- opt => opt.label.toLowerCase() === query.trim().toLowerCase()
1330
- );
1331
- if (exactMatch && !exactMatch.disabled) {
1332
- this._selectOption(exactMatch);
1333
- }
1334
- }
1335
-
1336
- /**
1337
- * Confirma la selección de una opción específica.
1338
- * Actualiza el valor (estado reactivo), cierra el dropdown y despacha los eventos
1339
- * estándar HTML `input` y `change` para integrarse fluidamente con formularios nativos o frameworks.
1340
- */
1341
- private _selectOption(option: OptionNode) {
1342
- if (option.disabled) return;
1343
- this.value = option.value;
1344
- this._closeDropdown();
1345
-
1346
- this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
1347
- this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
1348
- emitMoniEvent(this, 'moni-change', { detail: { value: this.value } });
1349
- }
1350
-
1351
- /**
1352
- * Maneja el Hover sobre categorías en modo Desktop (Submenús emergentes).
1353
- * Detecta dinámicamente si el submenú se saldrá del lado derecho de la pantalla.
1354
- * Si es así, altera las clases de CSS (`open-left` vs `open-right`) para que
1355
- * el menú flote en la dirección opuesta, evitando desbordamientos de viewport (Overflow prevention).
1356
- */
1357
- private _onSubmenuMouseEnter(e: MouseEvent) {
1358
- const headerEl = e.currentTarget as HTMLElement;
1359
- const submenu = headerEl.querySelector(':scope > .submenu') as HTMLElement;
1360
- if (!submenu) return;
1361
-
1362
- const rect = headerEl.getBoundingClientRect();
1363
- const spaceOnRight = window.innerWidth - rect.right;
1364
- const spaceOnLeft = rect.left;
1365
- const submenuWidth = 160;
1366
-
1367
- if (spaceOnRight < submenuWidth && spaceOnLeft > spaceOnRight) {
1368
- submenu.classList.remove('open-right');
1369
- submenu.classList.add('open-left');
1370
- } else {
1371
- submenu.classList.remove('open-left');
1372
- submenu.classList.add('open-right');
1373
- }
1374
- }
1375
-
1376
- /**
1377
- * Gestiona la navegación completa por teclado siguiendo el patrón WAI-ARIA Combobox.
1378
- * Soporta flechas direccionales, selección (Enter), y cierre (Escape).
1379
- */
1380
- private _onKeyDown(e: KeyboardEvent) {
1381
- if (this.disabled) return;
1382
-
1383
- // Obtenemos solo las opciones que están visualmente disponibles en la jerarquía actual
1384
- const visible = this._getVisibleOptions();
1385
-
1386
- if (e.key === 'ArrowDown') {
1387
- e.preventDefault();
1388
- if (!this._open) {
1389
- // Si está cerrado, ArrowDown lo abre (comportamiento estándar nativo)
1390
- this._openDropdown();
1391
- } else if (visible.length > 0) {
1392
- // Navegamos circularmente hacia abajo
1393
- this._activeIndex = (this._activeIndex + 1) % visible.length;
1394
- this._scrollToActive();
1395
- }
1396
- } else if (e.key === 'ArrowUp') {
1397
- e.preventDefault();
1398
- if (this._open && visible.length > 0) {
1399
- // Navegamos circularmente hacia arriba (+visible.length evita valores negativos en JS)
1400
- this._activeIndex = (this._activeIndex - 1 + visible.length) % visible.length;
1401
- this._scrollToActive();
1402
- }
1403
- } else if (e.key === 'Enter') {
1404
- e.preventDefault();
1405
- if (this._open) {
1406
- // Si está abierto y hay una opción resaltada, simulamos el click para seleccionarla
1407
- if (this._activeIndex >= 0 && this._activeIndex < visible.length) {
1408
- this._selectOption(visible[this._activeIndex]);
1409
- }
1410
- } else {
1411
- // Enter cuando está cerrado simplemente abre el menú
1412
- this._openDropdown();
1413
- }
1414
- } else if (e.key === 'Escape') {
1415
- e.preventDefault();
1416
- // Escapa el menú sin hacer ninguna selección
1417
- this._closeDropdown();
1418
- }
1419
- }
1420
-
1421
- /**
1422
- * Asegura que el elemento resaltado por teclado sea visible dentro del contenedor scrolleable.
1423
- */
1424
- private _scrollToActive() {
1425
- // Se requiere setTimeout para darle tiempo a Lit a renderizar la clase 'active-nav'
1426
- setTimeout(() => {
1427
- const activeEl = this.shadowRoot?.querySelector('.option-item.active-nav');
1428
- if (activeEl) {
1429
- activeEl.scrollIntoView({ block: 'nearest' });
1430
- }
1431
- }, 0);
1432
- }
1433
-
1434
- /**
1435
- * Aplana el árbol jerárquico recursivo de <moni-select-group> a un array unidimensional de opciones puras.
1436
- * Esencial para que la búsqueda por texto funcione globalmente ignorando las categorías.
1437
- */
1438
- private _getFlatOptions(nodes: DropdownNode[]): OptionNode[] {
1439
- const flat: OptionNode[] = [];
1440
- const traverse = (n: DropdownNode) => {
1441
- if (n.type === 'option') {
1442
- flat.push(n);
1443
- } else {
1444
- n.children.forEach(traverse);
1445
- }
1446
- };
1447
- nodes.forEach(traverse);
1448
- return flat;
1449
- }
1450
-
1451
- private _getFilteredOptions(): OptionNode[] {
1452
- const flat = this._getFlatOptions(this._parsedOptions);
1453
- if (!this._searchQuery) return flat;
1454
- const query = this._searchQuery.toLowerCase();
1455
- return flat.filter(opt =>
1456
- opt.label.toLowerCase().includes(query) || opt.value.toLowerCase().includes(query)
1457
- );
1458
- }
1459
-
1460
- private _getVisibleOptions(): OptionNode[] {
1461
- const filtered = this._getFilteredOptions();
1462
- if (this._searchQuery) return filtered;
1463
-
1464
- if (this.sheet || this._useInlineCategories) {
1465
- if (this._drilldownPath.length > 0) {
1466
- const activeGroup = this._drilldownPath[this._drilldownPath.length - 1];
1467
- return activeGroup.children.filter(n => n.type === 'option') as OptionNode[];
1468
- }
1469
- return this._parsedOptions.filter(n => n.type === 'option') as OptionNode[];
1470
- }
1471
-
1472
- return filtered;
1473
- }
1474
-
1475
- /**
1476
- * Motor de Renderizado: Lista Raíz (Modo Móvil / Drilldown).
1477
- *
1478
- * Renderiza las opciones iniciales y los grupos base de la categoría actual (`_drilldownPath`).
1479
- * Asigna clases dinámicas (`selected`, `active-nav`) para la retroalimentación visual
1480
- * del teclado y el valor actualmente seleccionado.
1481
- */
1482
- private _renderRootList() {
1483
- let visibleIndex = 0;
1484
- return this._parsedOptions.map(node => {
1485
- if (node.type === 'option') {
1486
- const currentIdx = visibleIndex++;
1487
- const isSelected = node.value === this.value;
1488
- const isActiveNav = this._drilldownPath.length === 0 && currentIdx === this._activeIndex;
1489
- return html`
1490
- <li
1491
- class="option-item ${isSelected ? 'selected' : ''} ${isActiveNav ? 'active-nav' : ''} ${node.disabled ? 'disabled' : ''}"
1492
- @click=${() => this._selectOption(node)}
1493
- >
1494
- ${node.label}
1495
- </li>
1496
- `;
1497
- }
1498
-
1499
- return html`
1500
- <li class="group-header" @click=${(e: Event) => { e.stopPropagation(); this._drilldownPath = [...this._drilldownPath, node]; }}>
1501
- <span>${node.label}</span>
1502
- <moni-icon name="chevron_right" style="margin-inline-start: auto; font-size: 1.125rem;"></moni-icon>
1503
- </li>
1504
- `;
1505
- });
1506
- }
1507
-
1508
- /**
1509
- * Motor de Renderizado: Subcategorías (Modo Móvil / Drilldown).
1510
- *
1511
- * A diferencia de desktop (donde los submenús flotan a los lados), en dispositivos
1512
- * móviles o espacios constreñidos, el menú transiciona in-situ. Este método renderiza
1513
- * el header del grupo activo, un botón de "Regresar" (Arrow Back) y las opciones hijas.
1514
- */
1515
- private _renderSubcategoryList() {
1516
- if (this._drilldownPath.length === 0) return nothing;
1517
- const activeGroup = this._drilldownPath[this._drilldownPath.length - 1];
1518
- let visibleIndex = 0;
1519
-
1520
- return html`
1521
- <li class="group-header" @click=${(e: Event) => { e.stopPropagation(); this._drilldownPath = this._drilldownPath.slice(0, -1); }}>
1522
- <moni-icon name="arrow_back" style="font-size: 1.25rem; margin-inline-end: 8px;"></moni-icon>
1523
- <span>Regresar</span>
1524
- </li>
1525
- <li class="group-header-flat">${activeGroup.label}</li>
1526
- ${activeGroup.children.map(node => {
1527
- if (node.type === 'option') {
1528
- const currentIdx = visibleIndex++;
1529
- const isSelected = node.value === this.value;
1530
- const isActiveNav = this._drilldownPath.length > 0 && currentIdx === this._activeIndex;
1531
- return html`
1532
- <li
1533
- class="option-item ${isSelected ? 'selected' : ''} ${isActiveNav ? 'active-nav' : ''} ${node.disabled ? 'disabled' : ''}"
1534
- @click=${() => this._selectOption(node)}
1535
- >
1536
- ${node.label}
1537
- </li>
1538
- `;
1539
- }
1540
-
1541
- return html`
1542
- <li class="group-header" @click=${(e: Event) => { e.stopPropagation(); this._drilldownPath = [...this._drilldownPath, node]; }}>
1543
- <span>${node.label}</span>
1544
- <moni-icon name="chevron_right" style="margin-inline-start: auto; font-size: 1.125rem;"></moni-icon>
1545
- </li>
1546
- `;
1547
- })}
1548
- `;
1549
- }
1550
-
1551
- private _renderDesktopNode(node: DropdownNode, flatIndexRef: { value: number }): any {
1552
- if (node.type === 'option') {
1553
- const currentIdx = flatIndexRef.value++;
1554
- const isSelected = node.value === this.value;
1555
- const isActiveNav = currentIdx === this._activeIndex;
1556
- return html`
1557
- <li
1558
- class="option-item ${isSelected ? 'selected' : ''} ${isActiveNav ? 'active-nav' : ''} ${node.disabled ? 'disabled' : ''}"
1559
- @click=${() => this._selectOption(node)}
1560
- >
1561
- ${node.label}
1562
- </li>
1563
- `;
1564
- }
1565
-
1566
- return html`
1567
- <li
1568
- class="group-header"
1569
- @mouseenter=${this._onSubmenuMouseEnter}
1570
- @click=${(e: Event) => e.stopPropagation()}
1571
- >
1572
- <span>${node.label}</span>
1573
- <moni-icon name="chevron_right" style="margin-inline-start: auto; font-size: 1.125rem;"></moni-icon>
1574
-
1575
- <ul class="submenu active open-right">
1576
- ${node.children.map(child => this._renderDesktopNode(child, flatIndexRef))}
1577
- </ul>
1578
- </li>
1579
- `;
1580
- }
1581
-
1582
- /**
1583
- * Orquestador principal de renderizado de la lista de opciones.
1584
- *
1585
- * Si el usuario introdujo texto (`_searchQuery`), renderiza los resultados filtrados.
1586
- * Si la lista original está vacía, muestra un mensaje amigable (Empty State).
1587
- * Determina condicionalmente si usar el modo plano/Drilldown (Móviles) o
1588
- * el modo Desktop con submenús flotantes (`_renderDesktopNode`).
1589
- */
1590
- private _renderOptionsList(filtered: OptionNode[]) {
1591
- if (this._parsedOptions.length === 0) {
1592
- return html`<li class="no-options">No options found</li>`;
1593
- }
1594
-
1595
- if (this._searchQuery) {
1596
- const groups: { [key: string]: OptionNode[] } = {};
1597
- filtered.forEach(opt => {
1598
- const gName = opt.group || '';
1599
- if (!groups[gName]) groups[gName] = [];
1600
- groups[gName].push(opt);
1601
- });
1602
-
1603
- let flatIndex = 0;
1604
- return Object.entries(groups).map(([groupName, groupOpts]) => html`
1605
- ${groupName ? html`<li class="group-header-flat">${groupName}</li>` : nothing}
1606
- ${groupOpts.map(opt => {
1607
- const currentIdx = flatIndex++;
1608
- const isSelected = opt.value === this.value;
1609
- const isActiveNav = currentIdx === this._activeIndex;
1610
- return html`
1611
- <li
1612
- class="option-item ${isSelected ? 'selected' : ''} ${isActiveNav ? 'active-nav' : ''} ${opt.disabled ? 'disabled' : ''}"
1613
- @click=${() => this._selectOption(opt)}
1614
- >
1615
- ${opt.label}
1616
- </li>
1617
- `;
1618
- })}
1619
- `);
1620
- }
1621
-
1622
- if (this.sheet || this._useInlineCategories) {
1623
- const isRootActive = this._drilldownPath.length === 0;
1624
- const isSubActive = !isRootActive;
1625
-
1626
- return html`
1627
- <div class="drilldown-wrapper">
1628
- <div class="drilldown-track ${this._drilldownPath.length > 0 ? 'slide-active' : ''}">
1629
- <ul class="drilldown-panel ${isRootActive ? 'panel-active' : 'panel-inactive'}">
1630
- ${this._renderRootList()}
1631
- </ul>
1632
- <ul class="drilldown-panel ${isSubActive ? 'panel-active' : 'panel-inactive'}">
1633
- ${this._renderSubcategoryList()}
1634
- </ul>
1635
- </div>
1636
- </div>
1637
- `;
1638
- }
1639
-
1640
- const flatIndexRef = { value: 0 };
1641
- return this._parsedOptions.map(node => this._renderDesktopNode(node, flatIndexRef));
1642
- }
1643
-
1644
- /**
1645
- * Ensambla el Shadow DOM del campo select, coordinando dos modos de interfaz de usuario distintos.
1646
- *
1647
- * **Resolución del valor a mostrar (Display value):**
1648
- * El contenido de texto visible en el input está determinado por prioridad:
1649
- * 1. Mientras el menú desplegable está abierto Y `searchable=true` Y NO es modo sheet:
1650
- * muestra `_searchQuery` (el texto del filtro en vivo que el usuario está escribiendo).
1651
- * 2. De lo contrario: muestra `selectedOpt.label` (la etiqueta de la opción actualmente
1652
- * seleccionada) o vuelve a la cadena vacía.
1653
- *
1654
- * **Dos rutas de renderizado:**
1655
- * - **Modo Sheet (`sheet=true`):** Renderiza un `<moni-bottom-sheet>` como un
1656
- * cajón (drawer) superpuesto a pantalla completa, ideal para IU táctiles móviles. La lista de opciones
1657
- * se renderiza dentro de la hoja en lugar del menú desplegable.
1658
- * - **Modo desplegable (por defecto):** Renderiza un `.dropdown-menu` flotante que
1659
- * se abre debajo o arriba del campo. La cadena `_menuStyle` (calculada por
1660
- * `_computeMenuStyle()`) inyecta los `top`, `left`, `width`, y
1661
- * sobreescrituras `position: fixed` correctas para contenedores con scroll (scroll-contained) y parents con overflow cortado (overflow-clipping).
1662
- *
1663
- * **Patrón combobox ARIA:**
1664
- * El input de texto lleva `role="combobox"`, `aria-haspopup="listbox"` y
1665
- * `aria-expanded` para satisfacer los requisitos del combobox WCAG 2.1 para lectores de pantalla.
1666
- * `aria-activedescendant` se establece al ID de la opción actualmente enfocada por teclado
1667
- * para habilitar el movimiento del cursor virtual sin gestión del foco.
1668
- *
1669
- * **Composición de `fieldClasses`:**
1670
- * Sigue la convención de nomenclatura de clases de estilos de campo de BeerCSS. `prefix` se agrega
1671
- * cuando `icon` está establecido (para desplazar el relleno -padding- inline-start del input de texto para el icono).
1672
- */
1673
- override render() {
1674
- const hasLeading = Boolean(this.icon);
1675
- const filtered = this._getFilteredOptions();
1676
-
1677
- // Find selected option label
1678
- const selectedOpt = this._findOptionByValue(this.value);
1679
- const displayValue = this._open && this.searchable && !this.sheet
1680
- ? this._searchQuery
1681
- : (selectedOpt ? selectedOpt.label : '');
1682
-
1683
- const isActive = Boolean(displayValue || this.placeholder);
1684
-
1685
- const fieldClasses = {
1686
- field: true,
1687
- label: Boolean(this.label),
1688
- fill: this.variant === 'filled',
1689
- border: this.variant === 'outlined',
1690
- small: this.size === 'small',
1691
- large: this.size === 'large',
1692
- extra: this.size === 'extra',
1693
- prefix: hasLeading,
1694
- suffix: true,
1695
- invalid: this.error,
1696
- round: this.shape === 'round',
1697
- 'small-round': this.shape === 'small-round',
1698
- square: this.shape === 'no-round'
1699
- };
1700
-
1701
- const leading = this.icon
1702
- ? html`<i class="leading-icon" part="leading-icon"
1703
- ><moni-icon name="${this.icon}"></moni-icon
1704
- ></i>`
1705
- : nothing;
1706
-
1707
- const trailing = this.loading
1708
- ? html`<i class="trailing-icon" part="trailing-icon"
1709
- ><moni-progress
1710
- variant="circular"
1711
- indeterminate
1712
- size="small"
1713
- style="inline-size: 1.25rem; block-size: 1.25rem; color: currentColor;"
1714
- ></moni-progress
1715
- ></i>`
1716
- : html`<i class="trailing-icon" part="trailing-icon" @click=${this._toggleDropdown}
1717
- ><moni-icon name="${this.trailingIcon}"></moni-icon
1718
- ></i>`;
1719
-
1720
- const hasGroups = this._parsedOptions.some(node => node.type === 'group');
1721
- const usesFlyoutSubmenus = hasGroups && !this._searchQuery && !this._useInlineCategories;
1722
- const menuClasses = {
1723
- 'dropdown-menu': true,
1724
- open: this._open,
1725
- scrollable: !usesFlyoutSubmenus,
1726
- searching: Boolean(this._searchQuery),
1727
- 'inline-categories': this._useInlineCategories,
1728
- 'placement-top': this._actualPlacement === 'top',
1729
- 'placement-bottom': this._actualPlacement === 'bottom',
1730
- 'placement-left': this._actualPlacement === 'left',
1731
- 'placement-right': this._actualPlacement === 'right'
1732
- };
1733
-
1734
- return html`<div class=${classMap(fieldClasses)} part="field">
1735
- ${leading}
1736
- <input
1737
- id="input"
1738
- type="text"
1739
- part="input"
1740
- .value=${displayValue}
1741
- ?readonly=${!this.searchable || this.sheet}
1742
- ?disabled=${this.disabled}
1743
- inputmode=${ifDefined(!this.searchable || this.sheet ? 'none' : undefined)}
1744
- placeholder=${this.placeholder || ''}
1745
- class=${isActive ? 'active' : ''}
1746
- @click=${this._onInputClick}
1747
- @input=${this._onSearchInput}
1748
- @keydown=${this._onKeyDown}
1749
- />
1750
- ${this.label
1751
- ? html`<label
1752
- for="input"
1753
- part="label"
1754
- class=${classMap({ active: isActive })}
1755
- >${this.label}</label
1756
- >`
1757
- : nothing}
1758
- ${trailing}
1759
-
1760
- ${this.sheet
1761
- ? html`
1762
- <div class="sheet-backdrop ${this._open ? 'open' : ''}" @click=${this._closeDropdown}>
1763
- <div class="sheet-drawer ${this._open ? 'open' : ''}" @click=${(e: Event) => e.stopPropagation()}>
1764
- <div class="sheet-handle"></div>
1765
- ${this.label ? html`<div class="sheet-title">${this.label}</div>` : nothing}
1766
- ${this.searchable
1767
- ? html`
1768
- <div class="sheet-search-wrapper">
1769
- <input
1770
- type="text"
1771
- class="sheet-search-input"
1772
- .value=${this._searchQuery}
1773
- placeholder="Buscar..."
1774
- @input=${this._onSearchInput}
1775
- />
1776
- </div>
1777
- `
1778
- : nothing}
1779
- <ul class="sheet-options-list">
1780
- ${this._renderOptionsList(filtered)}
1781
- </ul>
1782
- </div>
1783
- </div>
1784
- `
1785
- : html`
1786
- <ul
1787
- class=${classMap(menuClasses)}
1788
- part="menu"
1789
- popover=${this.positioning === 'body' ? 'manual' : nothing}
1790
- style=${this._getMenuStyle()}
1791
- >
1792
- ${this._renderOptionsList(filtered)}
1793
- </ul>
1794
- `}
1795
-
1796
- <slot style="display: none;" @slotchange=${this._onSlotChange}></slot>
1797
-
1798
- ${this.error
1799
- ? html`<output part="helper" class="invalid"
1800
- >${this.errorText || this.helper}</output
1801
- >`
1802
- : this.helper
1803
- ? html`<output part="helper">${this.helper}</output>`
1804
- : nothing}
1805
- </div>`;
1806
- }
1807
- }
1808
-
1809
- declare global {
1810
- interface HTMLElementTagNameMap {
1811
- 'moni-select': MoniSelect;
1812
- }
1813
- }
1814
-
1815
- export default MoniSelect;
1
+ /**
2
+ * @file components/moni-select.ts
3
+ * @package @moni-labs/moni-ui
4
+ * @license MIT
5
+ * @contributors Moni Labs & Contributors
6
+ */
7
+
8
+ import { html, css, nothing } from "lit";
9
+ import { customElement, property, state, query } from "lit/decorators.js";
10
+ import { ifDefined } from "lit/directives/if-defined.js";
11
+ import { classMap } from "lit/directives/class-map.js";
12
+ import { MoniElement, sharedStyles, fieldStyles } from "./_base/index.js";
13
+ import { emitMoniEvent } from "../utils/event-emitter.js";
14
+ import "./moni-icon.js";
15
+ import "./moni-progress.js";
16
+ import "./moni-select-option.js";
17
+
18
+ /**
19
+ * Representación interna de una opción individual dentro del menú desplegable del select.
20
+ *
21
+ * @internal
22
+ */
23
+ interface OptionNode {
24
+ /** Propiedad discriminante que identifica este nodo como una opción. */
25
+ type: "option";
26
+ /** El valor enviado cuando se selecciona esta opción. */
27
+ value: string;
28
+ /** La etiqueta mostrada en la lista desplegable. */
29
+ label: string;
30
+ /** Cuando es `true`, la opción no es interactiva y aparece atenuada (gris). */
31
+ disabled?: boolean;
32
+ /** Referencia al elemento original `<moni-select-option>` de la ranura (slot), si lo hay. */
33
+ element?: HTMLElement;
34
+ /** Nombre de grupo opcional para organizar opciones bajo un `<moni-select-group>`. */
35
+ group?: string;
36
+ }
37
+
38
+ /**
39
+ * Representación interna de un grupo de opciones dentro del menú desplegable del select.
40
+ *
41
+ * @internal
42
+ */
43
+ interface GroupNode {
44
+ /** Propiedad discriminante que identifica este nodo como un grupo. */
45
+ type: "group";
46
+ /** La etiqueta de encabezado del grupo mostrada sobre las opciones del grupo. */
47
+ label: string;
48
+ /** Las opciones anidadas dentro de este grupo. */
49
+ children: DropdownNode[];
50
+ }
51
+
52
+ /** Tipo de unión para cualquier nodo en el árbol del menú desplegable. @internal */
53
+ type DropdownNode = OptionNode | GroupNode;
54
+
55
+ /**
56
+ * Componente Material Design 3 Select (Menú Desplegable).
57
+ *
58
+ * Un menú desplegable select personalizado completo con capacidad de búsqueda, grupos de opciones,
59
+ * navegación por teclado, animación y un modo de cajón/hoja (drawer/sheet) móvil opcional.
60
+ * Reemplaza el elemento nativo `<select>` con una alternativa completamente estilizada y accesible
61
+ * que cumple con M3.
62
+ *
63
+ * **Referencia a la especificación M3:** `m3-docs/components/menus/specs.md` (menús desplegables)
64
+ *
65
+ * **Resumen de características:**
66
+ * - Variantes Filled y Outlined que coinciden con los estilos de campo de texto de M3.
67
+ * - Etiqueta flotante (floating label) con la animación estándar de etiqueta flotante de estilos de campo.
68
+ * - Modo de búsqueda: el atributo `searchable` agrega un campo de entrada de filtro en línea.
69
+ * - Grupos de opciones: elementos `<moni-select-group>` en el slot para opciones jerárquicas.
70
+ * - Cajón móvil: el atributo `drawer` abre las opciones en un `<moni-bottom-sheet>`
71
+ * en lugar de un popup desplegable, ideal para interfaces táctiles (touch UIs).
72
+ * - Navegación por teclado: Teclas de flecha, Enter, Escape y Tab según combobox de ARIA.
73
+ * - Estado de carga: `loading` muestra un progreso circular indeterminado.
74
+ * - Soporte multi-valor: `multiple` permite selección múltiple.
75
+ *
76
+ * **Fuentes de opciones:**
77
+ * Las opciones se pueden proporcionar de dos maneras:
78
+ * 1. **Elementos `<moni-select-option>` en la ranura (slot)** (por defecto, recomendado para SSR).
79
+ * 2. **Propiedad `options`** — un array de `DropdownNode[]` para un control completamente programático.
80
+ *
81
+ * **Vinculación de valores (Value binding):**
82
+ * La propiedad `value` contiene la cadena de valor de la opción actualmente seleccionada.
83
+ * Para selección múltiple, `values` contiene `string[]`. Al cambiar, se dispara un
84
+ * evento compuesto `'moni-change'`.
85
+ *
86
+ * @fires moni-change - Burbujea y está compuesto. Se dispara cuando el valor seleccionado cambia.
87
+ * Lee `element.value` (o `element.values` para `multiple`).
88
+ * @fires moni-select-open - Se dispara al abrirse para que los demás selects cierren sus menús.
89
+ *
90
+ * @example
91
+ * ```html
92
+ * <moni-select label="País" name="country" variant="outlined">
93
+ * <moni-select-option value="us">Estados Unidos</moni-select-option>
94
+ * <moni-select-option value="gb">Reino Unido</moni-select-option>
95
+ * <moni-select-option value="de">Alemania</moni-select-option>
96
+ * </moni-select>
97
+ *
98
+ * <!-- Select con búsqueda -->
99
+ * <moni-select label="Idioma" searchable>
100
+ * <moni-select-option value="ts">TypeScript</moni-select-option>
101
+ * <moni-select-option value="py">Python</moni-select-option>
102
+ * </moni-select>
103
+ * ```
104
+ *
105
+ * @slot default - Hijos `<moni-select-option>` o `<moni-select-group>`.
106
+ *
107
+ * @csspart field - El contenedor div exterior `.field`.
108
+ * @csspart dropdown - El contenedor flotante de la lista de opciones.
109
+ */
110
+ @customElement("moni-select")
111
+ export class MoniSelect extends MoniElement {
112
+ static formAssociated = true;
113
+ private _internals: ElementInternals;
114
+
115
+ constructor() {
116
+ super();
117
+ this._internals = this.attachInternals();
118
+ }
119
+
120
+ /**
121
+ * Nombre del campo select, usado para el envío del formulario.
122
+ * @type {string}
123
+ */
124
+ @property({ reflect: true }) name = "";
125
+
126
+ /**
127
+ * Texto de la etiqueta flotante.
128
+ * @type {string}
129
+ */
130
+ @property({ reflect: true }) label = "";
131
+
132
+ /**
133
+ * Variante visual del campo select.
134
+ * @type {'filled' | 'outlined'}
135
+ * @default 'filled'
136
+ */
137
+ @property({ reflect: true }) variant: "filled" | "outlined" = "filled";
138
+
139
+ /**
140
+ * Define las dimensiones del campo select.
141
+ * @type {'small' | 'medium' | 'large' | 'extra'}
142
+ * @default 'medium'
143
+ */
144
+ @property({ reflect: true })
145
+ size: "small" | "medium" | "large" | "extra" = "medium";
146
+
147
+ /**
148
+ * Forma (radio de borde) del campo.
149
+ * @type {'round' | 'small-round' | 'square' | 'no-round'}
150
+ * @default 'no-round'
151
+ */
152
+ @property({ reflect: true })
153
+ shape: "round" | "small-round" | "square" | "no-round" = "no-round";
154
+
155
+ /**
156
+ * Deshabilita el campo select.
157
+ * @type {boolean}
158
+ */
159
+ @property({ type: Boolean, reflect: true }) disabled = false;
160
+
161
+ /**
162
+ * Obliga a elegir una opción antes de enviar el formulario.
163
+ *
164
+ * El `<input>` visible es de sólo lectura, así que la validación nativa no
165
+ * se aplica sobre él: la restricción se declara en el host con
166
+ * `ElementInternals.setValidity`, que es lo que mira el formulario.
167
+ */
168
+ @property({ type: Boolean, reflect: true }) required = false;
169
+
170
+ /**
171
+ * Si es verdadero, muestra un indicador de carga (progreso lineal).
172
+ * @type {boolean}
173
+ */
174
+ @property({ type: Boolean, reflect: true }) loading = false;
175
+
176
+ /**
177
+ * Texto de ayuda mostrado debajo del campo.
178
+ * @type {string}
179
+ */
180
+ @property({ reflect: true }) helper = "";
181
+
182
+ /**
183
+ * Texto de error mostrado debajo del campo cuando `error` es true.
184
+ * Reemplaza al texto de ayuda.
185
+ * @type {string}
186
+ */
187
+ @property({ reflect: true, attribute: "error-text" }) errorText = "";
188
+
189
+ /**
190
+ * Si es verdadero, establece el campo en un estado de error.
191
+ * @type {boolean}
192
+ */
193
+ @property({ type: Boolean, reflect: true }) error = false;
194
+
195
+ /**
196
+ * El valor actual del select.
197
+ * @type {string}
198
+ */
199
+ @property({ reflect: true }) value = "";
200
+
201
+ /**
202
+ * Nombre del icono principal (leading icon, Material Symbols).
203
+ * @type {string}
204
+ */
205
+ @property({ reflect: true }) icon = "";
206
+
207
+ /**
208
+ * Nombre del icono final (trailing icon, Material Symbols) que indica el estado del menú desplegable.
209
+ * @type {string}
210
+ * @default 'arrow_drop_down'
211
+ */
212
+ @property({ reflect: true, attribute: "trailing-icon" }) trailingIcon =
213
+ "arrow_drop_down";
214
+
215
+ /**
216
+ * Habilita un campo de búsqueda en la parte superior del menú desplegable para filtrar opciones.
217
+ * @type {boolean}
218
+ */
219
+ @property({ type: Boolean, reflect: true }) searchable = false;
220
+
221
+ /**
222
+ * Muestra un botón de limpieza cuando se selecciona un valor para restablecer fácilmente el campo.
223
+ * @type {boolean}
224
+ */
225
+ @property({ type: Boolean, reflect: true }) clearable = false;
226
+
227
+ /**
228
+ * Renderiza las opciones como una hoja inferior (bottom sheet, ideal para dispositivos móviles) en lugar de un menú desplegable.
229
+ * @type {boolean}
230
+ */
231
+ @property({ type: Boolean, reflect: true }) sheet = false;
232
+
233
+ /**
234
+ * Texto de marcador de posición (placeholder) mostrado cuando no hay ningún valor seleccionado.
235
+ * @type {string}
236
+ */
237
+ @property({ reflect: true }) placeholder = "";
238
+
239
+ /**
240
+ * Estrategia de posicionamiento para el menú desplegable.
241
+ *
242
+ * - `'absolute'`: permanece en el contexto de posicionamiento del select.
243
+ * - `'fixed'`: usa coordenadas del viewport, pero conserva su contexto de apilamiento.
244
+ * - `'body'`: usa la capa superior nativa (Popover API) para escapar de `overflow`,
245
+ * diálogos, bottom sheets y contextos de apilamiento.
246
+ *
247
+ * @type {'absolute' | 'fixed' | 'body'}
248
+ * @default 'absolute'
249
+ */
250
+ @property({ reflect: true }) positioning: "absolute" | "fixed" | "body" =
251
+ "absolute";
252
+
253
+ /**
254
+ * Ubicación preferida del menú desplegable con respecto al disparador (trigger).
255
+ * @type {'top' | 'bottom' | 'left' | 'right' | 'auto'}
256
+ * @default 'auto'
257
+ */
258
+ @property({ reflect: true }) placement:
259
+ "top" | "bottom" | "left" | "right" | "auto" = "auto";
260
+
261
+ /**
262
+ * Restricción de ancho del menú desplegable.
263
+ * - `'trigger'`: Coincide con el ancho del campo de entrada.
264
+ * - `'auto'`: Coincide con el ancho del contenido del menú desplegable.
265
+ * - O cualquier valor CSS de ancho válido (ej. '200px').
266
+ * @type {string}
267
+ * @default 'trigger'
268
+ */
269
+ @property({ reflect: true, attribute: "dropdown-width" }) dropdownWidth =
270
+ "trigger";
271
+
272
+ /**
273
+ * Límite manual opcional para la altura del menú.
274
+ *
275
+ * Acepta cualquier longitud CSS válida, por ejemplo `240px`, `40vh` o
276
+ * `calc(100vh - 8rem)`. El valor nunca supera el límite automático calculado
277
+ * según el viewport, la posición del campo y el tope predeterminado de 75vh.
278
+ * Una cadena vacía conserva el cálculo automático.
279
+ *
280
+ * @default ''
281
+ */
282
+ @property({ reflect: true, attribute: "dropdown-max-height" })
283
+ dropdownMaxHeight = "";
284
+
285
+ @state() private _open = false;
286
+ @state() private _searchQuery = "";
287
+ @state() private _actualPlacement: "top" | "bottom" | "left" | "right" =
288
+ "bottom";
289
+ @state() private _parsedOptions: DropdownNode[] = [];
290
+ @state() private _activeIndex = -1;
291
+ @state() private _menuStyle = "";
292
+ @state() private _menuMaxHeight = "75vh";
293
+ @state() private _useInlineCategories = false;
294
+ @state() private _drilldownPath: GroupNode[] = [];
295
+
296
+ @query("slot") private _slot!: HTMLSlotElement;
297
+ @query("input") private _input!: HTMLInputElement;
298
+
299
+ /**
300
+ * Declara la validez del host según `required`.
301
+ *
302
+ * No se puede delegar en el `<input>` interno porque es `readonly`, y un
303
+ * campo de sólo lectura queda excluido de la validación de restricciones. El
304
+ * ancla sigue siendo ese input para que el navegador tenga dónde mostrar el
305
+ * mensaje y a dónde llevar el foco.
306
+ */
307
+ /**
308
+ * API de validación equivalente a la de un control nativo.
309
+ *
310
+ * Los elementos form-associated participan en la validación del formulario,
311
+ * pero no reciben estos miembros automáticamente: hay que exponerlos para que
312
+ * un `moni-select` se pueda interrogar igual que un `<input>`.
313
+ */
314
+ get validity(): ValidityState {
315
+ return this._internals.validity;
316
+ }
317
+
318
+ get validationMessage(): string {
319
+ return this._internals.validationMessage;
320
+ }
321
+
322
+ checkValidity(): boolean {
323
+ return this._internals.checkValidity();
324
+ }
325
+
326
+ reportValidity(): boolean {
327
+ return this._internals.reportValidity();
328
+ }
329
+
330
+ private _syncValidity() {
331
+ if (!this._internals?.setValidity) return;
332
+ if (this.required && !this.value) {
333
+ this._internals.setValidity(
334
+ { valueMissing: true },
335
+ "Selecciona una opción.",
336
+ this._input ?? undefined,
337
+ );
338
+ return;
339
+ }
340
+ this._internals.setValidity({});
341
+ }
342
+ @query(".dropdown-menu") private _menu?: HTMLElement;
343
+
344
+ static override styles = [
345
+ sharedStyles,
346
+ fieldStyles,
347
+ css`
348
+ :host {
349
+ position: relative;
350
+ display: block;
351
+ }
352
+
353
+ .field {
354
+ cursor: pointer;
355
+ }
356
+
357
+ input[type="text"] {
358
+ cursor: pointer;
359
+ width: 100%;
360
+ }
361
+
362
+ input[type="text"][readonly] {
363
+ caret-color: transparent;
364
+ user-select: none;
365
+ }
366
+
367
+ /* Dropdown menu overlay */
368
+ .dropdown-menu {
369
+ position: absolute;
370
+ top: 100%;
371
+ left: 0;
372
+ right: 0;
373
+ z-index: 100;
374
+ background-color: var(--surface-container);
375
+ box-shadow: var(--elevate3);
376
+ border-radius: 0.5rem;
377
+ margin-top: 4px;
378
+ padding: 4px 0;
379
+ list-style: none;
380
+ opacity: 0;
381
+ visibility: hidden;
382
+ transform: scale(0.95) translateY(-8px);
383
+ transform-origin: top;
384
+ transition:
385
+ opacity var(--speed3) cubic-bezier(0.2, 0.8, 0.2, 1),
386
+ transform var(--speed3) cubic-bezier(0.34, 1.56, 0.64, 1),
387
+ visibility var(--speed3);
388
+ }
389
+
390
+ .dropdown-menu.open {
391
+ opacity: 1;
392
+ visibility: visible;
393
+ transform: scale(1) translateY(0);
394
+ }
395
+
396
+ /* positioning="body" promotes the menu to the browser's top layer.
397
+ Reset the Popover UA box while retaining Shadow DOM styles. */
398
+ .dropdown-menu[popover] {
399
+ margin: 0;
400
+ border: 0;
401
+ }
402
+
403
+ .dropdown-menu.placement-top {
404
+ top: auto;
405
+ bottom: 100%;
406
+ margin-top: 0;
407
+ margin-bottom: 4px;
408
+ transform-origin: bottom;
409
+ transform: scale(0.95) translateY(8px);
410
+ }
411
+
412
+ .dropdown-menu.placement-top.open {
413
+ transform: scale(1) translateY(0);
414
+ }
415
+
416
+ .dropdown-menu.placement-left {
417
+ top: 0;
418
+ left: auto;
419
+ right: 100%;
420
+ margin-top: 0;
421
+ margin-right: 4px;
422
+ transform-origin: right center;
423
+ transform: scale(0.95) translateX(8px);
424
+ }
425
+
426
+ .dropdown-menu.placement-left.open {
427
+ transform: scale(1) translateX(0);
428
+ }
429
+
430
+ .dropdown-menu.placement-right {
431
+ top: 0;
432
+ left: 100%;
433
+ right: auto;
434
+ margin-top: 0;
435
+ margin-left: 4px;
436
+ transform-origin: left center;
437
+ transform: scale(0.95) translateX(-8px);
438
+ }
439
+
440
+ .dropdown-menu.placement-right.open {
441
+ transform: scale(1) translateX(0);
442
+ }
443
+
444
+ .dropdown-menu.open.scrollable {
445
+ overflow-x: hidden;
446
+ overflow-y: auto;
447
+ overscroll-behavior: contain;
448
+ }
449
+
450
+ /* Desktop category flyouts need visible overflow. If the root list no
451
+ longer fits, the component switches to the inline drilldown layout. */
452
+ .dropdown-menu.open:not(.scrollable) {
453
+ overflow: visible;
454
+ }
455
+
456
+ /* Bottom sheet drawer styles */
457
+ .sheet-backdrop {
458
+ position: fixed;
459
+ inset: 0;
460
+ z-index: 1000;
461
+ background-color: rgba(0, 0, 0, 0.4);
462
+ opacity: 0;
463
+ visibility: hidden;
464
+ transition:
465
+ opacity var(--speed3) cubic-bezier(0.2, 0.8, 0.2, 1),
466
+ visibility var(--speed3);
467
+ display: flex;
468
+ flex-direction: column;
469
+ justify-content: flex-end;
470
+ }
471
+
472
+ .sheet-backdrop.open {
473
+ opacity: 1;
474
+ visibility: visible;
475
+ }
476
+
477
+ .sheet-drawer {
478
+ background-color: var(--surface-container);
479
+ border-radius: 1.75rem 1.75rem 0 0;
480
+ box-shadow: var(--elevate3);
481
+ max-height: 70vh;
482
+ display: flex;
483
+ flex-direction: column;
484
+ transform: translateY(100%);
485
+ transition: transform var(--speed3) cubic-bezier(0.16, 1, 0.3, 1);
486
+ padding: 16px 0;
487
+ }
488
+
489
+ .sheet-drawer.open {
490
+ transform: translateY(0);
491
+ }
492
+
493
+ .sheet-handle {
494
+ width: 40px;
495
+ height: 4px;
496
+ background-color: var(--outline-variant);
497
+ border-radius: 2px;
498
+ margin: 0 auto 16px;
499
+ }
500
+
501
+ .sheet-title {
502
+ padding: 0 24px 8px;
503
+ font-size: 1.125rem;
504
+ font-weight: 700;
505
+ color: var(--on-surface);
506
+ }
507
+
508
+ .sheet-search-wrapper {
509
+ padding: 0 16px 12px;
510
+ }
511
+
512
+ .sheet-search-input {
513
+ width: 100%;
514
+ box-sizing: border-box;
515
+ padding: 10px 16px;
516
+ border: 1px solid var(--outline);
517
+ border-radius: 9999px;
518
+ background: var(--surface-container-high);
519
+ color: var(--on-surface);
520
+ font-size: 1rem;
521
+ outline: none;
522
+ }
523
+
524
+ .sheet-search-input:focus {
525
+ border-color: var(--primary);
526
+ }
527
+
528
+ .sheet-options-list {
529
+ list-style: none;
530
+ padding: 0;
531
+ margin: 0;
532
+ overflow-y: auto;
533
+ flex: 1;
534
+ }
535
+
536
+ /* Animated Drilldown Slide Layout */
537
+ .drilldown-wrapper {
538
+ width: 100%;
539
+ overflow: hidden;
540
+ position: relative;
541
+ transition: height var(--speed3) cubic-bezier(0.2, 0.8, 0.2, 1);
542
+ }
543
+
544
+ .drilldown-track {
545
+ display: flex;
546
+ width: 200%;
547
+ transition: transform var(--speed3) cubic-bezier(0.2, 0.8, 0.2, 1);
548
+ }
549
+
550
+ .drilldown-track.slide-active {
551
+ transform: translateX(-50%);
552
+ }
553
+
554
+ .drilldown-panel {
555
+ width: 50%;
556
+ box-sizing: border-box;
557
+ list-style: none;
558
+ padding: 0;
559
+ margin: 0;
560
+ flex-shrink: 0;
561
+ }
562
+
563
+ .drilldown-panel.panel-active {
564
+ max-height: none;
565
+ }
566
+
567
+ .drilldown-panel.panel-inactive {
568
+ max-height: 0;
569
+ overflow: hidden;
570
+ }
571
+
572
+ /* Group / Subcategory header (Browsing mode - acts as a hover trigger for submenus) */
573
+ .group-header {
574
+ padding: 8px 16px;
575
+ font-size: 0.875rem;
576
+ font-weight: 500;
577
+ color: var(--on-surface);
578
+ cursor: pointer;
579
+ display: flex;
580
+ align-items: center;
581
+ position: relative;
582
+ user-select: none;
583
+ transition:
584
+ background-color var(--speed2) ease,
585
+ color var(--speed2) ease;
586
+ }
587
+
588
+ .group-header:hover,
589
+ .group-header.active {
590
+ background-color: var(--surface-container-high);
591
+ color: var(--primary);
592
+ }
593
+
594
+ /* Inline header (Searching mode & Sheet mode - flat layout) */
595
+ .group-header-flat {
596
+ padding: 6px 16px;
597
+ font-size: 0.75rem;
598
+ font-weight: 700;
599
+ text-transform: uppercase;
600
+ color: var(--primary);
601
+ letter-spacing: 0.05em;
602
+ background-color: var(--surface-container-low);
603
+ user-select: none;
604
+ }
605
+
606
+ /* Hierarchical sub-menu with premium flyout animation */
607
+ .submenu {
608
+ display: block;
609
+ opacity: 0;
610
+ visibility: hidden;
611
+ position: absolute;
612
+ top: 0;
613
+ /* Intermediate flyouts must keep overflow visible so deeper
614
+ subcategories are never clipped. Oversized trees switch to the
615
+ scrollable inline drilldown layout before rendering. */
616
+ overflow: visible;
617
+ background-color: var(--surface-container-highest);
618
+ box-shadow: var(--elevate3);
619
+ border-radius: 0.5rem;
620
+ padding: 4px 0;
621
+ list-style: none;
622
+ min-width: 160px;
623
+ z-index: 110;
624
+ transition:
625
+ opacity var(--speed2) cubic-bezier(0.2, 0.8, 0.2, 1),
626
+ transform var(--speed2) cubic-bezier(0.34, 1.56, 0.64, 1),
627
+ visibility var(--speed2);
628
+ }
629
+
630
+ .submenu.open-right {
631
+ left: 100%;
632
+ margin-left: 2px;
633
+ transform: scale(0.9) translateX(-10px);
634
+ transform-origin: top left;
635
+ }
636
+
637
+ .submenu.open-left {
638
+ right: 100%;
639
+ margin-right: 2px;
640
+ transform: scale(0.9) translateX(10px);
641
+ transform-origin: top right;
642
+ }
643
+
644
+ .group-header:hover > .submenu.active,
645
+ .group-header.active > .submenu.active {
646
+ opacity: 1;
647
+ visibility: visible;
648
+ transform: scale(1) translateX(0);
649
+ }
650
+
651
+ .option-item {
652
+ cursor: pointer;
653
+ padding: 8px 16px;
654
+ font-size: 0.875rem;
655
+ color: var(--on-surface);
656
+ display: flex;
657
+ align-items: center;
658
+ transition: background-color var(--speed2) ease;
659
+ }
660
+
661
+ .option-item:hover,
662
+ .option-item.active-nav {
663
+ background-color: var(--surface-container-high);
664
+ }
665
+
666
+ .option-item.selected {
667
+ background-color: var(--tertiary-container);
668
+ color: var(--on-tertiary-container);
669
+ font-weight: 500;
670
+ }
671
+
672
+ .option-item.disabled {
673
+ opacity: 0.5;
674
+ pointer-events: none;
675
+ }
676
+
677
+ .no-options {
678
+ padding: 12px 16px;
679
+ font-size: 0.875rem;
680
+ color: var(--outline);
681
+ text-align: center;
682
+ }
683
+ `,
684
+ ];
685
+
686
+ /**
687
+ * Hook del ciclo de vida (Lit).
688
+ * Vincula event listeners globales necesarios para la lógica del dropdown.
689
+ * Se usa `capture: true` en el evento de scroll para poder reaccionar
690
+ * al desplazamiento de cualquier contenedor padre y reposicionar el menú a tiempo.
691
+ */
692
+ override connectedCallback() {
693
+ super.connectedCallback();
694
+ document.addEventListener("click", this._handleOutsideClick);
695
+ document.addEventListener(
696
+ "moni-select-open",
697
+ this._handlePeerOpen as EventListener,
698
+ );
699
+ window.addEventListener("scroll", this._handleScroll, { capture: true });
700
+ window.addEventListener("resize", this._handleResize);
701
+ window.visualViewport?.addEventListener("scroll", this._handleScroll);
702
+ window.visualViewport?.addEventListener("resize", this._handleResize);
703
+ }
704
+
705
+ /**
706
+ * Hook del ciclo de vida (Lit).
707
+ * Desmonta rigurosamente todos los listeners globales para prevenir memory leaks
708
+ * o llamadas accidentales cuando el select ya fue destruido del DOM.
709
+ */
710
+ override disconnectedCallback() {
711
+ this._hideBodyPopover();
712
+ super.disconnectedCallback();
713
+ document.removeEventListener("click", this._handleOutsideClick);
714
+ document.removeEventListener(
715
+ "moni-select-open",
716
+ this._handlePeerOpen as EventListener,
717
+ );
718
+ window.removeEventListener("scroll", this._handleScroll, { capture: true });
719
+ window.removeEventListener("resize", this._handleResize);
720
+ window.visualViewport?.removeEventListener("scroll", this._handleScroll);
721
+ window.visualViewport?.removeEventListener("resize", this._handleResize);
722
+ }
723
+
724
+ /**
725
+ * Hook de actualización reactiva (Lit).
726
+ * Verifica si alguna propiedad relacionada con el tamaño del contenido (como la búsqueda,
727
+ * la ruta de anidación o el estado abierto) ha cambiado, para forzar un recálculo
728
+ * de la altura animada del contenedor `drilldown-wrapper`.
729
+ */
730
+ override updated(changedProperties: Map<string | number | symbol, unknown>) {
731
+ super.updated(changedProperties);
732
+ if (changedProperties.has("value")) {
733
+ this._internals?.setFormValue?.(this.value);
734
+ }
735
+ this._syncValidity();
736
+ if (
737
+ changedProperties.has("_open") ||
738
+ changedProperties.has("_drilldownPath") ||
739
+ changedProperties.has("_searchQuery") ||
740
+ changedProperties.has("_useInlineCategories") ||
741
+ changedProperties.has("_parsedOptions")
742
+ ) {
743
+ this._updateWrapperHeight();
744
+ }
745
+ if (
746
+ this._open &&
747
+ (changedProperties.has("positioning") ||
748
+ changedProperties.has("placement") ||
749
+ changedProperties.has("dropdownWidth") ||
750
+ changedProperties.has("dropdownMaxHeight"))
751
+ ) {
752
+ this._updateMenuPosition();
753
+ }
754
+ if (
755
+ changedProperties.has("_open") ||
756
+ changedProperties.has("positioning") ||
757
+ changedProperties.has("sheet")
758
+ ) {
759
+ this._syncBodyPopover();
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Lógica de animación de altura (Height Animation).
765
+ * Cuando se navega entre categorías anidadas (`_drilldownPath`), las listas hijas pueden
766
+ * tener diferentes tamaños. Este método lee el `scrollHeight` del panel activo y lo aplica
767
+ * imperativamente al `wrapper` para disparar una transición CSS suave en la altura del dropdown.
768
+ */
769
+ private _updateWrapperHeight() {
770
+ const wrapper = this.shadowRoot?.querySelector(
771
+ ".drilldown-wrapper",
772
+ ) as HTMLElement;
773
+ if (!wrapper) return;
774
+
775
+ if (!this._open || this.sheet) {
776
+ wrapper.style.height = "";
777
+ return;
778
+ }
779
+
780
+ // Wait for Lit render and styles to apply
781
+ requestAnimationFrame(() => {
782
+ const activePanel = this.shadowRoot?.querySelector(
783
+ ".drilldown-panel.panel-active",
784
+ ) as HTMLElement;
785
+ if (activePanel) {
786
+ wrapper.style.height = `${activePanel.scrollHeight}px`;
787
+ } else {
788
+ wrapper.style.height = "";
789
+ }
790
+ });
791
+ }
792
+
793
+ /**
794
+ * Cierra el Dropdown cuando el usuario hace clic fuera de las dimensiones del componente.
795
+ * Utiliza `e.composedPath()` para poder atravesar barreras de Shadow DOM si el select
796
+ * está dentro de un contenedor web-component externo.
797
+ */
798
+ private _handleOutsideClick = (e: MouseEvent) => {
799
+ if (this._open && !e.composedPath().includes(this)) {
800
+ this._closeDropdown();
801
+ }
802
+ };
803
+
804
+ /** Cierra este menú cuando otro `<moni-select>` anuncia que va a abrirse. */
805
+ private _handlePeerOpen = (e: Event) => {
806
+ const source = (e as CustomEvent<{ item?: MoniSelect }>).detail?.item;
807
+ if (source && source !== this && this._open) {
808
+ this._closeDropdown();
809
+ }
810
+ };
811
+
812
+ /**
813
+ * Sincroniza el modo `positioning="body"` con la Popover API. Un popover
814
+ * permanece en este Shadow DOM para conservar estilos y listeners, pero el
815
+ * navegador lo promueve a la capa superior, fuera de cualquier `overflow`.
816
+ */
817
+ private _syncBodyPopover() {
818
+ const menu = this._menu;
819
+ if (!menu || typeof menu.showPopover !== "function") return;
820
+
821
+ const shouldBeOpen =
822
+ this._open && !this.sheet && this.positioning === "body";
823
+ try {
824
+ const isOpen = menu.matches(":popover-open");
825
+ if (shouldBeOpen && !isOpen) {
826
+ menu.showPopover();
827
+ } else if (!shouldBeOpen && isOpen) {
828
+ menu.hidePopover();
829
+ }
830
+ } catch {
831
+ /* Browsers without a complete Popover implementation use fixed fallback. */
832
+ }
833
+ }
834
+
835
+ private _hideBodyPopover() {
836
+ const menu = this._menu;
837
+ if (!menu || typeof menu.hidePopover !== "function") return;
838
+ try {
839
+ if (menu.matches(":popover-open")) menu.hidePopover();
840
+ } catch {
841
+ /* No-op during teardown or in partial DOM implementations. */
842
+ }
843
+ }
844
+
845
+ /**
846
+ * Motor de Cálculo de Colisiones de la Interfaz (Collision Detection).
847
+ *
848
+ * @logic
849
+ * 1. Calcula las coordenadas espaciales (`getBoundingClientRect`) del select base.
850
+ * 2. Compara el espacio disponible (`spaceAbove`, `spaceBelow`, `spaceRight`, `spaceLeft`)
851
+ * respecto al viewport (`window.innerHeight`, `window.innerWidth`).
852
+ * 3. Si el dropdown no cabe en la posición preferida (`placement`), revierte lógicamente
853
+ * la posición (ej: si `bottom` desborda, lo abre hacia `top`).
854
+ * 4. Actualiza `_actualPlacement` con la dirección segura calculada.
855
+ */
856
+ private _getViewportMetrics() {
857
+ const viewport = window.visualViewport;
858
+ const top = viewport?.offsetTop ?? 0;
859
+ const left = viewport?.offsetLeft ?? 0;
860
+ const height = viewport?.height ?? window.innerHeight;
861
+ const width = viewport?.width ?? window.innerWidth;
862
+ return {
863
+ top,
864
+ left,
865
+ height,
866
+ width,
867
+ bottom: top + height,
868
+ right: left + width,
869
+ };
870
+ }
871
+
872
+ /**
873
+ * Calcula el espacio vertical en una dirección. Además del espacio físico,
874
+ * descuenta la posición del trigger del presupuesto de 75% del viewport:
875
+ * `75vh - offset del campo`, tal como se espera para campos a media pantalla.
876
+ */
877
+ private _getDirectionalAvailableHeight(
878
+ direction: "top" | "bottom",
879
+ rect: DOMRectReadOnly,
880
+ ): number {
881
+ const viewport = this._getViewportMetrics();
882
+ const gap = 8;
883
+ const viewportCap = viewport.height * 0.75;
884
+
885
+ if (direction === "bottom") {
886
+ const physicalSpace = viewport.bottom - rect.bottom - gap;
887
+ const triggerOffset = rect.bottom - viewport.top + gap;
888
+ return Math.max(
889
+ 0,
890
+ Math.floor(
891
+ Math.min(viewportCap, physicalSpace, viewportCap - triggerOffset),
892
+ ),
893
+ );
894
+ }
895
+
896
+ const physicalSpace = rect.top - viewport.top - gap;
897
+ const triggerOffset = viewport.bottom - rect.top + gap;
898
+ return Math.max(
899
+ 0,
900
+ Math.floor(
901
+ Math.min(viewportCap, physicalSpace, viewportCap - triggerOffset),
902
+ ),
903
+ );
904
+ }
905
+
906
+ private _normalizeManualMaxHeight(): string {
907
+ const raw = this.dropdownMaxHeight.trim();
908
+ if (!raw) return "";
909
+ const normalized = /^\d+(?:\.\d+)?$/.test(raw) ? `${raw}px` : raw;
910
+ if (
911
+ typeof CSS !== "undefined" &&
912
+ typeof CSS.supports === "function" &&
913
+ !CSS.supports("max-height", normalized)
914
+ ) {
915
+ return "";
916
+ }
917
+ return normalized;
918
+ }
919
+
920
+ /** Convierte las unidades manuales comunes a px para el motor de colisiones. */
921
+ private _resolveManualMaxHeightPx(
922
+ value: string,
923
+ viewportHeight: number,
924
+ ): number | null {
925
+ const match = value.match(/^(\d+(?:\.\d+)?)(px|vh|dvh|svh|lvh|%|rem)$/i);
926
+ if (!match) return null;
927
+ const amount = Number(match[1]);
928
+ const unit = match[2].toLowerCase();
929
+ if (unit === "px") return amount;
930
+ if (unit === "rem") {
931
+ const rootSize =
932
+ Number.parseFloat(
933
+ getComputedStyle(document.documentElement).fontSize,
934
+ ) || 16;
935
+ return amount * rootSize;
936
+ }
937
+ return viewportHeight * (amount / 100);
938
+ }
939
+
940
+ private _getMenuHeightConstraint(
941
+ rect: DOMRectReadOnly,
942
+ placement = this._actualPlacement,
943
+ ): { css: string; px: number } {
944
+ const viewport = this._getViewportMetrics();
945
+ const automatic =
946
+ placement === "top" || placement === "bottom"
947
+ ? this._getDirectionalAvailableHeight(placement, rect)
948
+ : Math.max(
949
+ 0,
950
+ Math.floor(Math.min(viewport.height * 0.75, viewport.height - 16)),
951
+ );
952
+ const manualCss = this._normalizeManualMaxHeight();
953
+ const manualPx = manualCss
954
+ ? this._resolveManualMaxHeightPx(manualCss, viewport.height)
955
+ : null;
956
+ const px = manualPx === null ? automatic : Math.min(automatic, manualPx);
957
+
958
+ return {
959
+ css: manualCss ? `min(${automatic}px, ${manualCss})` : `${automatic}px`,
960
+ px: Math.max(0, px),
961
+ };
962
+ }
963
+
964
+ private _estimateDesiredMenuHeight(): number {
965
+ const viewport = this._getViewportMetrics();
966
+ const itemCount = this._searchQuery
967
+ ? this._getFilteredOptions().length
968
+ : this._parsedOptions.length;
969
+ let desired = Math.min(
970
+ viewport.height * 0.75,
971
+ Math.max(48, itemCount * 40 + 8),
972
+ );
973
+ const manual = this._normalizeManualMaxHeight();
974
+ const manualPx = manual
975
+ ? this._resolveManualMaxHeightPx(manual, viewport.height)
976
+ : null;
977
+ if (manualPx !== null) desired = Math.min(desired, manualPx);
978
+ return desired;
979
+ }
980
+
981
+ /** Profundidad máxima de flyouts necesaria para representar el árbol. */
982
+ private _getGroupDepth(nodes: DropdownNode[]): number {
983
+ return nodes.reduce((depth, node) => {
984
+ if (node.type === "option") return depth;
985
+ return Math.max(depth, 1 + this._getGroupDepth(node.children));
986
+ }, 0);
987
+ }
988
+
989
+ /** Detecta cualquier nivel cuya lista directa necesitaría scroll propio. */
990
+ private _hasOversizedGroupPanel(
991
+ nodes: DropdownNode[],
992
+ maxHeight: number,
993
+ ): boolean {
994
+ return nodes.some((node) => {
995
+ if (node.type === "option") return false;
996
+ const panelHeight = node.children.length * 40 + 8;
997
+ return (
998
+ panelHeight > maxHeight ||
999
+ this._hasOversizedGroupPanel(node.children, maxHeight)
1000
+ );
1001
+ });
1002
+ }
1003
+
1004
+ /**
1005
+ * Mide el espacio horizontal real para flyouts. En modo local también
1006
+ * intersecta ancestros con overflow para no diseñar submenús fuera de una
1007
+ * región que terminaría recortándolos. `body` ignora esos ancestros porque
1008
+ * se renderiza en la top layer.
1009
+ */
1010
+ private _getAvailableFlyoutWidth(rect: DOMRectReadOnly): number {
1011
+ const viewport = this._getViewportMetrics();
1012
+ let boundaryLeft = viewport.left;
1013
+ let boundaryRight = viewport.right;
1014
+
1015
+ if (this.positioning !== "body") {
1016
+ let current: Node | null = this;
1017
+ while (current) {
1018
+ const parent: Node | null =
1019
+ current.parentNode instanceof ShadowRoot
1020
+ ? current.parentNode.host
1021
+ : current.parentNode;
1022
+ if (parent instanceof HTMLElement) {
1023
+ const styles = getComputedStyle(parent);
1024
+ if (/(auto|scroll|hidden|clip)/.test(styles.overflowX)) {
1025
+ const parentRect = parent.getBoundingClientRect();
1026
+ boundaryLeft = Math.max(boundaryLeft, parentRect.left);
1027
+ boundaryRight = Math.min(boundaryRight, parentRect.right);
1028
+ }
1029
+ }
1030
+ current = parent;
1031
+ }
1032
+ }
1033
+
1034
+ return Math.max(
1035
+ 0,
1036
+ Math.max(boundaryRight - rect.right, rect.left - boundaryLeft),
1037
+ );
1038
+ }
1039
+
1040
+ private _determineActualPlacement() {
1041
+ if (this.sheet) {
1042
+ this._actualPlacement = "bottom";
1043
+ return;
1044
+ }
1045
+
1046
+ const rect = this.getBoundingClientRect();
1047
+ const viewport = this._getViewportMetrics();
1048
+ const spaceBelow = this._getDirectionalAvailableHeight("bottom", rect);
1049
+ const spaceAbove = this._getDirectionalAvailableHeight("top", rect);
1050
+ const spaceRight = viewport.right - rect.right;
1051
+ const spaceLeft = rect.left - viewport.left;
1052
+ const menuHeight = this._estimateDesiredMenuHeight();
1053
+ const menuWidth = 180;
1054
+
1055
+ let preferred = this.placement;
1056
+ if (preferred === "auto") {
1057
+ preferred =
1058
+ spaceBelow >= menuHeight
1059
+ ? "bottom"
1060
+ : spaceAbove > spaceBelow
1061
+ ? "top"
1062
+ : "bottom";
1063
+ }
1064
+
1065
+ if (preferred === "top") {
1066
+ this._actualPlacement =
1067
+ spaceAbove < menuHeight && spaceBelow > spaceAbove ? "bottom" : "top";
1068
+ } else if (preferred === "bottom") {
1069
+ this._actualPlacement =
1070
+ spaceBelow < menuHeight && spaceAbove > spaceBelow ? "top" : "bottom";
1071
+ } else if (preferred === "left") {
1072
+ this._actualPlacement =
1073
+ spaceLeft < menuWidth && spaceRight > spaceLeft ? "right" : "left";
1074
+ } else if (preferred === "right") {
1075
+ this._actualPlacement =
1076
+ spaceRight < menuWidth && spaceLeft > spaceRight ? "left" : "right";
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * Handler de Scroll global.
1082
+ * Fuerza un recálculo de la posición y dimensiones del dropdown si el usuario
1083
+ * hace scroll en la página mientras el select permanece abierto.
1084
+ */
1085
+ private _handleScroll = () => {
1086
+ if (this._open) {
1087
+ this._determineActualPlacement();
1088
+ this._updateMenuPosition();
1089
+ }
1090
+ };
1091
+
1092
+ /**
1093
+ * Handler de Resize global.
1094
+ * Asegura que el popover del select no quede fuera del viewport cuando
1095
+ * se cambia el tamaño de la ventana o se rota la pantalla en dispositivos móviles.
1096
+ */
1097
+ private _handleResize = () => {
1098
+ if (this._open) {
1099
+ this._determineActualPlacement();
1100
+ this._updateMenuPosition();
1101
+ }
1102
+ };
1103
+
1104
+ /**
1105
+ * Aplica las coordenadas y dimensiones absolutas o fijas al menú desplegable.
1106
+ * Este método es fundamental cuando `positioning === 'fixed'`, ya que el dropdown
1107
+ * se saca del flujo normal del documento para evitar `overflow: hidden` de los padres.
1108
+ * Mapea el `width` para coincidir opcionalmente con el gatillo (`dropdownWidth="trigger"`).
1109
+ */
1110
+ private _updateMenuPosition() {
1111
+ if (!this._open || this.sheet) return;
1112
+
1113
+ this._determineActualPlacement();
1114
+ const rect = this.getBoundingClientRect();
1115
+ const viewport = this._getViewportMetrics();
1116
+ const constraint = this._getMenuHeightConstraint(rect);
1117
+ this._menuMaxHeight = constraint.css;
1118
+
1119
+ const hasGroups = this._parsedOptions.some((node) => node.type === "group");
1120
+ const estimatedRootHeight = this._parsedOptions.length * 40 + 8;
1121
+ const groupDepth = this._getGroupDepth(this._parsedOptions);
1122
+ const requiredFlyoutWidth = groupDepth * 160;
1123
+ const availableFlyoutWidth = this._getAvailableFlyoutWidth(rect);
1124
+ const lacksFlyoutSpace =
1125
+ viewport.width < 600 ||
1126
+ (hasGroups && availableFlyoutWidth < requiredFlyoutWidth);
1127
+ const needsVerticalDrilldown =
1128
+ hasGroups &&
1129
+ (estimatedRootHeight > constraint.px ||
1130
+ this._hasOversizedGroupPanel(this._parsedOptions, constraint.px));
1131
+ this._useInlineCategories = lacksFlyoutSpace || needsVerticalDrilldown;
1132
+
1133
+ if (this.positioning !== "fixed" && this.positioning !== "body") {
1134
+ this._menuStyle = "";
1135
+ return;
1136
+ }
1137
+
1138
+ let widthStyle = "";
1139
+ if (this.dropdownWidth === "trigger") {
1140
+ widthStyle = `width: ${rect.width}px;`;
1141
+ } else if (this.dropdownWidth === "auto") {
1142
+ widthStyle = "width: auto; min-width: 160px; max-width: 320px;";
1143
+ } else {
1144
+ widthStyle = `width: ${this.dropdownWidth};`;
1145
+ }
1146
+
1147
+ if (this._actualPlacement === "top") {
1148
+ this._menuStyle = `
1149
+ position: fixed;
1150
+ bottom: ${window.innerHeight - rect.top}px;
1151
+ left: ${rect.left}px;
1152
+ ${widthStyle}
1153
+ top: auto;
1154
+ right: auto;
1155
+ `;
1156
+ } else if (this._actualPlacement === "bottom") {
1157
+ this._menuStyle = `
1158
+ position: fixed;
1159
+ top: ${rect.bottom}px;
1160
+ left: ${rect.left}px;
1161
+ ${widthStyle}
1162
+ bottom: auto;
1163
+ right: auto;
1164
+ `;
1165
+ } else {
1166
+ const sideTop = Math.max(
1167
+ viewport.top + 8,
1168
+ Math.min(rect.top, viewport.bottom - constraint.px - 8),
1169
+ );
1170
+ if (this._actualPlacement === "left") {
1171
+ this._menuStyle = `
1172
+ position: fixed;
1173
+ top: ${sideTop}px;
1174
+ right: ${window.innerWidth - rect.left + 4}px;
1175
+ ${widthStyle}
1176
+ left: auto;
1177
+ bottom: auto;
1178
+ `;
1179
+ } else {
1180
+ this._menuStyle = `
1181
+ position: fixed;
1182
+ top: ${sideTop}px;
1183
+ left: ${rect.right + 4}px;
1184
+ ${widthStyle}
1185
+ right: auto;
1186
+ bottom: auto;
1187
+ `;
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Devuelve las coordenadas, el ancho y la restricción dinámica de altura del
1194
+ * menú. `body` comparte coordenadas fixed, pero se pinta en la capa superior.
1195
+ */
1196
+ private _getMenuStyle() {
1197
+ const constraintStyle = `max-height: ${this._menuMaxHeight}; --_select-menu-max-height: ${this._menuMaxHeight};`;
1198
+ if (this.positioning === "fixed" || this.positioning === "body") {
1199
+ return `${this._menuStyle} ${constraintStyle}`;
1200
+ }
1201
+
1202
+ if (this.dropdownWidth === "auto") {
1203
+ return `width: max-content; min-width: 160px; max-width: 320px; ${constraintStyle}`;
1204
+ } else if (this.dropdownWidth !== "trigger") {
1205
+ return `width: ${this.dropdownWidth}; ${constraintStyle}`;
1206
+ }
1207
+ return constraintStyle;
1208
+ }
1209
+
1210
+ /**
1211
+ * Parseador del Shadow DOM (Slot Change Handler).
1212
+ *
1213
+ * @logic
1214
+ * 1. Cuando los hijos proyectados cambian (ej. se agregan nuevos `<option>`),
1215
+ * lee iterativamente todos los nodos del Light DOM.
1216
+ * 2. Si encuentra `<optgroup>` o `<moni-select-option group="A/B">`, construye dinámicamente
1217
+ * un árbol estructural de `GroupNode` anidados.
1218
+ * 3. Si encuentra opciones, las inyecta en el grupo correspondiente del árbol.
1219
+ * 4. Almacena el árbol resultante en `_parsedOptions` para que el componente
1220
+ * pueda renderizar sus propios submenús nativos independientemente del markup inicial.
1221
+ */
1222
+ private _onSlotChange() {
1223
+ if (!this._slot) return;
1224
+ const assigned = this._slot.assignedElements({ flatten: true });
1225
+
1226
+ const tree: DropdownNode[] = [];
1227
+
1228
+ const findOrCreateGroup = (
1229
+ nodes: DropdownNode[],
1230
+ label: string,
1231
+ ): GroupNode => {
1232
+ let group = nodes.find(
1233
+ (n) => n.type === "group" && n.label === label,
1234
+ ) as GroupNode;
1235
+ if (!group) {
1236
+ group = { type: "group", label, children: [] };
1237
+ nodes.push(group);
1238
+ }
1239
+ return group;
1240
+ };
1241
+
1242
+ const insertOption = (
1243
+ targetTree: DropdownNode[],
1244
+ opt: OptionNode,
1245
+ groupPath: string[],
1246
+ ) => {
1247
+ let currentLevel = targetTree;
1248
+ if (groupPath.length > 0) {
1249
+ opt.group = groupPath.join(" / ");
1250
+ }
1251
+ groupPath.forEach((part) => {
1252
+ const group = findOrCreateGroup(currentLevel, part);
1253
+ currentLevel = group.children;
1254
+ });
1255
+ currentLevel.push(opt);
1256
+ };
1257
+
1258
+ const parseElement = (el: Element, parentGroupPath: string[]) => {
1259
+ const tag = el.tagName.toLowerCase();
1260
+ if (tag === "option" || tag === "moni-select-option") {
1261
+ const opt: OptionNode = {
1262
+ type: "option",
1263
+ value: el.getAttribute("value") || "",
1264
+ label:
1265
+ el.textContent?.trim() ||
1266
+ el.getAttribute("label") ||
1267
+ el.getAttribute("value") ||
1268
+ "",
1269
+ disabled: el.hasAttribute("disabled"),
1270
+ element: el as HTMLElement,
1271
+ };
1272
+ const groupAttr = el.getAttribute("group");
1273
+ const localPath = groupAttr
1274
+ ? groupAttr
1275
+ .split("/")
1276
+ .map((s) => s.trim())
1277
+ .filter(Boolean)
1278
+ : [];
1279
+ const finalPath = [...parentGroupPath, ...localPath];
1280
+ insertOption(tree, opt, finalPath);
1281
+ } else if (tag === "optgroup") {
1282
+ const label = el.getAttribute("label") || "";
1283
+ const localPath = [...parentGroupPath, label];
1284
+ Array.from(el.children).forEach((child) =>
1285
+ parseElement(child, localPath),
1286
+ );
1287
+ }
1288
+ };
1289
+
1290
+ assigned.forEach((el) => parseElement(el, []));
1291
+ this._parsedOptions = tree;
1292
+ this.requestUpdate();
1293
+ }
1294
+
1295
+ /**
1296
+ * Alterna el estado de apertura/cierre del dropdown, previniendo
1297
+ * la acción si el componente está deshabilitado.
1298
+ */
1299
+ private _toggleDropdown(e?: Event) {
1300
+ if (e) {
1301
+ e.stopPropagation();
1302
+ }
1303
+ if (this.disabled) return;
1304
+ if (this._open) {
1305
+ this._closeDropdown();
1306
+ } else {
1307
+ this._openDropdown();
1308
+ }
1309
+ }
1310
+
1311
+ /**
1312
+ * Manejador de clics en el campo de texto (Trigger).
1313
+ * Si el componente es `searchable` (permite escribir para buscar), un clic
1314
+ * sólo abrirá el menú (no lo cerrará si ya estaba abierto, para permitir al usuario seguir escribiendo).
1315
+ */
1316
+ private _onInputClick(e: MouseEvent) {
1317
+ if (this.disabled) return;
1318
+ if (this.searchable && !this.sheet) {
1319
+ if (!this._open) {
1320
+ this._openDropdown();
1321
+ }
1322
+ } else {
1323
+ this._toggleDropdown(e);
1324
+ }
1325
+ }
1326
+
1327
+ /**
1328
+ * Despliega el menú del select y coordina la accesibilidad, posicionamiento y foco inicial.
1329
+ */
1330
+ private _openDropdown() {
1331
+ if (this.disabled || this._open) return;
1332
+
1333
+ this._open = true;
1334
+ this._activeIndex = -1;
1335
+ this._drilldownPath = [];
1336
+ // La búsqueda siempre comienza limpia, aunque exista un valor seleccionado.
1337
+ this._searchQuery = "";
1338
+
1339
+ // El evento global permite que cualquier otro select abierto se cierre,
1340
+ // incluso cuando la apertura vino del teclado o del icono trailing.
1341
+ emitMoniEvent(this, "moni-select-open", { detail: { item: this } });
1342
+
1343
+ this._updateMenuPosition();
1344
+
1345
+ if (this.searchable) {
1346
+ // Esperamos un tick para que el input o sheet esté renderizado antes de enfocarlo.
1347
+ setTimeout(() => {
1348
+ if (!this._open) return;
1349
+ if (this._input && !this.sheet) {
1350
+ this._input.focus();
1351
+ } else if (this.sheet) {
1352
+ const sheetInput = this.shadowRoot?.querySelector(
1353
+ ".sheet-search-input",
1354
+ ) as HTMLInputElement;
1355
+ sheetInput?.focus();
1356
+ }
1357
+ }, 50);
1358
+ }
1359
+ }
1360
+
1361
+ /**
1362
+ * Cierra el dropdown y purga la consulta de búsqueda (`_searchQuery`)
1363
+ * para que la próxima vez que se abra, la lista muestre todas las opciones por defecto.
1364
+ */
1365
+ private _closeDropdown() {
1366
+ if (!this._open) return;
1367
+ this._open = false;
1368
+ this._searchQuery = "";
1369
+ this._hideBodyPopover();
1370
+ }
1371
+
1372
+ private _findOptionByValue(value: string): OptionNode | undefined {
1373
+ return this._getFlatOptions(this._parsedOptions).find(
1374
+ (opt) => opt.value === value,
1375
+ );
1376
+ }
1377
+
1378
+ /**
1379
+ * Evento de input (tecleo) en la barra de búsqueda.
1380
+ *
1381
+ * @logic
1382
+ * 1. Actualiza `_searchQuery` y fuerza la apertura del dropdown.
1383
+ * 2. Si es `clearable` y el usuario borró todo, emite eventos vacíos.
1384
+ * 3. Busca coincidencias exactas en el texto. Si el usuario teclea exactamente el
1385
+ * label de una opción existente, la selecciona automáticamente (auto-completado nativo).
1386
+ */
1387
+ private _onSearchInput(e: Event) {
1388
+ const query = (e.target as HTMLInputElement).value;
1389
+ if (!this._open) this._openDropdown();
1390
+ this._searchQuery = query;
1391
+ this._activeIndex = -1;
1392
+
1393
+ if (this.clearable && query.trim() === "") {
1394
+ this.value = "";
1395
+ emitMoniEvent(this, "moni-change", {
1396
+ detail: { value: this.value, originalEvent: e },
1397
+ });
1398
+ return;
1399
+ }
1400
+
1401
+ const flat = this._getFlatOptions(this._parsedOptions);
1402
+ const exactMatch = flat.find(
1403
+ (opt) => opt.label.toLowerCase() === query.trim().toLowerCase(),
1404
+ );
1405
+ if (exactMatch && !exactMatch.disabled) {
1406
+ this._selectOption(exactMatch);
1407
+ }
1408
+ }
1409
+
1410
+ /**
1411
+ * Confirma la selección de una opción específica.
1412
+ * Actualiza el valor (estado reactivo), cierra el dropdown y despacha los eventos
1413
+ * estándar HTML `input` y `change` para integrarse fluidamente con formularios nativos o frameworks.
1414
+ */
1415
+ private _selectOption(option: OptionNode) {
1416
+ if (option.disabled) return;
1417
+ this.value = option.value;
1418
+ this._closeDropdown();
1419
+
1420
+ this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
1421
+ this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
1422
+ emitMoniEvent(this, "moni-change", { detail: { value: this.value } });
1423
+ }
1424
+
1425
+ /**
1426
+ * Maneja el Hover sobre categorías en modo Desktop (Submenús emergentes).
1427
+ * Detecta dinámicamente si el submenú se saldrá del lado derecho de la pantalla.
1428
+ * Si es así, altera las clases de CSS (`open-left` vs `open-right`) para que
1429
+ * el menú flote en la dirección opuesta, evitando desbordamientos de viewport (Overflow prevention).
1430
+ */
1431
+ private _onSubmenuMouseEnter(e: MouseEvent) {
1432
+ const headerEl = e.currentTarget as HTMLElement;
1433
+ const submenu = headerEl.querySelector(":scope > .submenu") as HTMLElement;
1434
+ if (!submenu) return;
1435
+
1436
+ const rect = headerEl.getBoundingClientRect();
1437
+ const spaceOnRight = window.innerWidth - rect.right;
1438
+ const spaceOnLeft = rect.left;
1439
+ const submenuWidth = 160;
1440
+
1441
+ if (spaceOnRight < submenuWidth && spaceOnLeft > spaceOnRight) {
1442
+ submenu.classList.remove("open-right");
1443
+ submenu.classList.add("open-left");
1444
+ } else {
1445
+ submenu.classList.remove("open-left");
1446
+ submenu.classList.add("open-right");
1447
+ }
1448
+ }
1449
+
1450
+ /**
1451
+ * Gestiona la navegación completa por teclado siguiendo el patrón WAI-ARIA Combobox.
1452
+ * Soporta flechas direccionales, selección (Enter), y cierre (Escape).
1453
+ */
1454
+ private _onKeyDown(e: KeyboardEvent) {
1455
+ if (this.disabled) return;
1456
+
1457
+ // Obtenemos solo las opciones que están visualmente disponibles en la jerarquía actual
1458
+ const visible = this._getVisibleOptions();
1459
+
1460
+ if (e.key === "ArrowDown") {
1461
+ e.preventDefault();
1462
+ if (!this._open) {
1463
+ // Si está cerrado, ArrowDown lo abre (comportamiento estándar nativo)
1464
+ this._openDropdown();
1465
+ } else if (visible.length > 0) {
1466
+ // Navegamos circularmente hacia abajo
1467
+ this._activeIndex = (this._activeIndex + 1) % visible.length;
1468
+ this._scrollToActive();
1469
+ }
1470
+ } else if (e.key === "ArrowUp") {
1471
+ e.preventDefault();
1472
+ if (this._open && visible.length > 0) {
1473
+ // Navegamos circularmente hacia arriba (+visible.length evita valores negativos en JS)
1474
+ this._activeIndex =
1475
+ (this._activeIndex - 1 + visible.length) % visible.length;
1476
+ this._scrollToActive();
1477
+ }
1478
+ } else if (e.key === "Enter") {
1479
+ e.preventDefault();
1480
+ if (this._open) {
1481
+ // Si está abierto y hay una opción resaltada, simulamos el click para seleccionarla
1482
+ if (this._activeIndex >= 0 && this._activeIndex < visible.length) {
1483
+ this._selectOption(visible[this._activeIndex]);
1484
+ }
1485
+ } else {
1486
+ // Enter cuando está cerrado simplemente abre el menú
1487
+ this._openDropdown();
1488
+ }
1489
+ } else if (e.key === "Escape") {
1490
+ e.preventDefault();
1491
+ // Escapa el menú sin hacer ninguna selección
1492
+ this._closeDropdown();
1493
+ }
1494
+ }
1495
+
1496
+ /**
1497
+ * Asegura que el elemento resaltado por teclado sea visible dentro del contenedor scrolleable.
1498
+ */
1499
+ private _scrollToActive() {
1500
+ // Se requiere setTimeout para darle tiempo a Lit a renderizar la clase 'active-nav'
1501
+ setTimeout(() => {
1502
+ const activeEl = this.shadowRoot?.querySelector(
1503
+ ".option-item.active-nav",
1504
+ );
1505
+ if (activeEl) {
1506
+ activeEl.scrollIntoView({ block: "nearest" });
1507
+ }
1508
+ }, 0);
1509
+ }
1510
+
1511
+ /**
1512
+ * Aplana el árbol jerárquico recursivo de <moni-select-group> a un array unidimensional de opciones puras.
1513
+ * Esencial para que la búsqueda por texto funcione globalmente ignorando las categorías.
1514
+ */
1515
+ private _getFlatOptions(nodes: DropdownNode[]): OptionNode[] {
1516
+ const flat: OptionNode[] = [];
1517
+ const traverse = (n: DropdownNode) => {
1518
+ if (n.type === "option") {
1519
+ flat.push(n);
1520
+ } else {
1521
+ n.children.forEach(traverse);
1522
+ }
1523
+ };
1524
+ nodes.forEach(traverse);
1525
+ return flat;
1526
+ }
1527
+
1528
+ private _getFilteredOptions(): OptionNode[] {
1529
+ const flat = this._getFlatOptions(this._parsedOptions);
1530
+ if (!this._searchQuery) return flat;
1531
+ const query = this._searchQuery.toLowerCase();
1532
+ return flat.filter(
1533
+ (opt) =>
1534
+ opt.label.toLowerCase().includes(query) ||
1535
+ opt.value.toLowerCase().includes(query),
1536
+ );
1537
+ }
1538
+
1539
+ private _getVisibleOptions(): OptionNode[] {
1540
+ const filtered = this._getFilteredOptions();
1541
+ if (this._searchQuery) return filtered;
1542
+
1543
+ if (this.sheet || this._useInlineCategories) {
1544
+ if (this._drilldownPath.length > 0) {
1545
+ const activeGroup = this._drilldownPath[this._drilldownPath.length - 1];
1546
+ return activeGroup.children.filter(
1547
+ (n) => n.type === "option",
1548
+ ) as OptionNode[];
1549
+ }
1550
+ return this._parsedOptions.filter(
1551
+ (n) => n.type === "option",
1552
+ ) as OptionNode[];
1553
+ }
1554
+
1555
+ return filtered;
1556
+ }
1557
+
1558
+ /**
1559
+ * Motor de Renderizado: Lista Raíz (Modo Móvil / Drilldown).
1560
+ *
1561
+ * Renderiza las opciones iniciales y los grupos base de la categoría actual (`_drilldownPath`).
1562
+ * Asigna clases dinámicas (`selected`, `active-nav`) para la retroalimentación visual
1563
+ * del teclado y el valor actualmente seleccionado.
1564
+ */
1565
+ private _renderRootList() {
1566
+ let visibleIndex = 0;
1567
+ return this._parsedOptions.map((node) => {
1568
+ if (node.type === "option") {
1569
+ const currentIdx = visibleIndex++;
1570
+ const isSelected = node.value === this.value;
1571
+ const isActiveNav =
1572
+ this._drilldownPath.length === 0 && currentIdx === this._activeIndex;
1573
+ return html`
1574
+ <li
1575
+ class="option-item ${isSelected ? "selected" : ""} ${isActiveNav ? "active-nav" : ""} ${node.disabled ? "disabled" : ""}"
1576
+ @click=${() => this._selectOption(node)}
1577
+ >
1578
+ ${node.label}
1579
+ </li>
1580
+ `;
1581
+ }
1582
+
1583
+ return html`
1584
+ <li
1585
+ class="group-header"
1586
+ @click=${(e: Event) => {
1587
+ e.stopPropagation();
1588
+ this._drilldownPath = [...this._drilldownPath, node];
1589
+ }}
1590
+ >
1591
+ <span>${node.label}</span>
1592
+ <moni-icon
1593
+ name="chevron_right"
1594
+ style="margin-inline-start: auto; font-size: 1.125rem;"
1595
+ ></moni-icon>
1596
+ </li>
1597
+ `;
1598
+ });
1599
+ }
1600
+
1601
+ /**
1602
+ * Motor de Renderizado: Subcategorías (Modo Móvil / Drilldown).
1603
+ *
1604
+ * A diferencia de desktop (donde los submenús flotan a los lados), en dispositivos
1605
+ * móviles o espacios constreñidos, el menú transiciona in-situ. Este método renderiza
1606
+ * el header del grupo activo, un botón de "Regresar" (Arrow Back) y las opciones hijas.
1607
+ */
1608
+ private _renderSubcategoryList() {
1609
+ if (this._drilldownPath.length === 0) return nothing;
1610
+ const activeGroup = this._drilldownPath[this._drilldownPath.length - 1];
1611
+ let visibleIndex = 0;
1612
+
1613
+ return html`
1614
+ <li
1615
+ class="group-header"
1616
+ @click=${(e: Event) => {
1617
+ e.stopPropagation();
1618
+ this._drilldownPath = this._drilldownPath.slice(0, -1);
1619
+ }}
1620
+ >
1621
+ <moni-icon
1622
+ name="arrow_back"
1623
+ style="font-size: 1.25rem; margin-inline-end: 8px;"
1624
+ ></moni-icon>
1625
+ <span>Regresar</span>
1626
+ </li>
1627
+ <li class="group-header-flat">${activeGroup.label}</li>
1628
+ ${activeGroup.children.map((node) => {
1629
+ if (node.type === "option") {
1630
+ const currentIdx = visibleIndex++;
1631
+ const isSelected = node.value === this.value;
1632
+ const isActiveNav =
1633
+ this._drilldownPath.length > 0 && currentIdx === this._activeIndex;
1634
+ return html`
1635
+ <li
1636
+ class="option-item ${isSelected ? "selected" : ""} ${isActiveNav ? "active-nav" : ""} ${node.disabled ? "disabled" : ""}"
1637
+ @click=${() => this._selectOption(node)}
1638
+ >
1639
+ ${node.label}
1640
+ </li>
1641
+ `;
1642
+ }
1643
+
1644
+ return html`
1645
+ <li
1646
+ class="group-header"
1647
+ @click=${(e: Event) => {
1648
+ e.stopPropagation();
1649
+ this._drilldownPath = [...this._drilldownPath, node];
1650
+ }}
1651
+ >
1652
+ <span>${node.label}</span>
1653
+ <moni-icon
1654
+ name="chevron_right"
1655
+ style="margin-inline-start: auto; font-size: 1.125rem;"
1656
+ ></moni-icon>
1657
+ </li>
1658
+ `;
1659
+ })}
1660
+ `;
1661
+ }
1662
+
1663
+ private _renderDesktopNode(
1664
+ node: DropdownNode,
1665
+ flatIndexRef: { value: number },
1666
+ ): any {
1667
+ if (node.type === "option") {
1668
+ const currentIdx = flatIndexRef.value++;
1669
+ const isSelected = node.value === this.value;
1670
+ const isActiveNav = currentIdx === this._activeIndex;
1671
+ return html`
1672
+ <li
1673
+ class="option-item ${isSelected ? "selected" : ""} ${isActiveNav ? "active-nav" : ""} ${node.disabled ? "disabled" : ""}"
1674
+ @click=${() => this._selectOption(node)}
1675
+ >
1676
+ ${node.label}
1677
+ </li>
1678
+ `;
1679
+ }
1680
+
1681
+ return html`
1682
+ <li
1683
+ class="group-header"
1684
+ @mouseenter=${this._onSubmenuMouseEnter}
1685
+ @click=${(e: Event) => e.stopPropagation()}
1686
+ >
1687
+ <span>${node.label}</span>
1688
+ <moni-icon
1689
+ name="chevron_right"
1690
+ style="margin-inline-start: auto; font-size: 1.125rem;"
1691
+ ></moni-icon>
1692
+
1693
+ <ul class="submenu active open-right">
1694
+ ${node.children.map((child) => this._renderDesktopNode(child, flatIndexRef))}
1695
+ </ul>
1696
+ </li>
1697
+ `;
1698
+ }
1699
+
1700
+ /**
1701
+ * Orquestador principal de renderizado de la lista de opciones.
1702
+ *
1703
+ * Si el usuario introdujo texto (`_searchQuery`), renderiza los resultados filtrados.
1704
+ * Si la lista original está vacía, muestra un mensaje amigable (Empty State).
1705
+ * Determina condicionalmente si usar el modo plano/Drilldown (Móviles) o
1706
+ * el modo Desktop con submenús flotantes (`_renderDesktopNode`).
1707
+ */
1708
+ private _renderOptionsList(filtered: OptionNode[]) {
1709
+ if (this._parsedOptions.length === 0) {
1710
+ return html`<li class="no-options">No options found</li>`;
1711
+ }
1712
+
1713
+ if (this._searchQuery) {
1714
+ const groups: { [key: string]: OptionNode[] } = {};
1715
+ filtered.forEach((opt) => {
1716
+ const gName = opt.group || "";
1717
+ if (!groups[gName]) groups[gName] = [];
1718
+ groups[gName].push(opt);
1719
+ });
1720
+
1721
+ let flatIndex = 0;
1722
+ return Object.entries(groups).map(
1723
+ ([groupName, groupOpts]) => html`
1724
+ ${groupName ? html`<li class="group-header-flat">${groupName}</li>` : nothing}
1725
+ ${groupOpts.map((opt) => {
1726
+ const currentIdx = flatIndex++;
1727
+ const isSelected = opt.value === this.value;
1728
+ const isActiveNav = currentIdx === this._activeIndex;
1729
+ return html`
1730
+ <li
1731
+ class="option-item ${isSelected ? "selected" : ""} ${isActiveNav ? "active-nav" : ""} ${opt.disabled ? "disabled" : ""}"
1732
+ @click=${() => this._selectOption(opt)}
1733
+ >
1734
+ ${opt.label}
1735
+ </li>
1736
+ `;
1737
+ })}
1738
+ `,
1739
+ );
1740
+ }
1741
+
1742
+ if (this.sheet || this._useInlineCategories) {
1743
+ const isRootActive = this._drilldownPath.length === 0;
1744
+ const isSubActive = !isRootActive;
1745
+
1746
+ return html`
1747
+ <div class="drilldown-wrapper">
1748
+ <div
1749
+ class="drilldown-track ${this._drilldownPath.length > 0 ? "slide-active" : ""}"
1750
+ >
1751
+ <ul
1752
+ class="drilldown-panel ${isRootActive ? "panel-active" : "panel-inactive"}"
1753
+ >
1754
+ ${this._renderRootList()}
1755
+ </ul>
1756
+ <ul
1757
+ class="drilldown-panel ${isSubActive ? "panel-active" : "panel-inactive"}"
1758
+ >
1759
+ ${this._renderSubcategoryList()}
1760
+ </ul>
1761
+ </div>
1762
+ </div>
1763
+ `;
1764
+ }
1765
+
1766
+ const flatIndexRef = { value: 0 };
1767
+ return this._parsedOptions.map((node) =>
1768
+ this._renderDesktopNode(node, flatIndexRef),
1769
+ );
1770
+ }
1771
+
1772
+ /**
1773
+ * Ensambla el Shadow DOM del campo select, coordinando dos modos de interfaz de usuario distintos.
1774
+ *
1775
+ * **Resolución del valor a mostrar (Display value):**
1776
+ * El contenido de texto visible en el input está determinado por prioridad:
1777
+ * 1. Mientras el menú desplegable está abierto Y `searchable=true` Y NO es modo sheet:
1778
+ * muestra `_searchQuery` (el texto del filtro en vivo que el usuario está escribiendo).
1779
+ * 2. De lo contrario: muestra `selectedOpt.label` (la etiqueta de la opción actualmente
1780
+ * seleccionada) o vuelve a la cadena vacía.
1781
+ *
1782
+ * **Dos rutas de renderizado:**
1783
+ * - **Modo Sheet (`sheet=true`):** Renderiza un `<moni-bottom-sheet>` como un
1784
+ * cajón (drawer) superpuesto a pantalla completa, ideal para IU táctiles móviles. La lista de opciones
1785
+ * se renderiza dentro de la hoja en lugar del menú desplegable.
1786
+ * - **Modo desplegable (por defecto):** Renderiza un `.dropdown-menu` flotante que
1787
+ * se abre debajo o arriba del campo. La cadena `_menuStyle` (calculada por
1788
+ * `_computeMenuStyle()`) inyecta los `top`, `left`, `width`, y
1789
+ * sobreescrituras `position: fixed` correctas para contenedores con scroll (scroll-contained) y parents con overflow cortado (overflow-clipping).
1790
+ *
1791
+ * **Patrón combobox ARIA:**
1792
+ * El input de texto lleva `role="combobox"`, `aria-haspopup="listbox"` y
1793
+ * `aria-expanded` para satisfacer los requisitos del combobox WCAG 2.1 para lectores de pantalla.
1794
+ * `aria-activedescendant` se establece al ID de la opción actualmente enfocada por teclado
1795
+ * para habilitar el movimiento del cursor virtual sin gestión del foco.
1796
+ *
1797
+ * **Composición de `fieldClasses`:**
1798
+ * Sigue la convención de nomenclatura de clases de estilos de campo de BeerCSS. `prefix` se agrega
1799
+ * cuando `icon` está establecido (para desplazar el relleno -padding- inline-start del input de texto para el icono).
1800
+ */
1801
+ override render() {
1802
+ const hasLeading = Boolean(this.icon);
1803
+ const filtered = this._getFilteredOptions();
1804
+
1805
+ // Find selected option label
1806
+ const selectedOpt = this._findOptionByValue(this.value);
1807
+ const displayValue =
1808
+ this._open && this.searchable && !this.sheet
1809
+ ? this._searchQuery
1810
+ : selectedOpt
1811
+ ? selectedOpt.label
1812
+ : "";
1813
+
1814
+ const isActive = Boolean(displayValue || this.placeholder);
1815
+
1816
+ const fieldClasses = {
1817
+ field: true,
1818
+ label: Boolean(this.label),
1819
+ fill: this.variant === "filled",
1820
+ border: this.variant === "outlined",
1821
+ small: this.size === "small",
1822
+ large: this.size === "large",
1823
+ extra: this.size === "extra",
1824
+ prefix: hasLeading,
1825
+ suffix: true,
1826
+ invalid: this.error,
1827
+ round: this.shape === "round",
1828
+ "small-round": this.shape === "small-round",
1829
+ square: this.shape === "no-round",
1830
+ };
1831
+
1832
+ const leading = this.icon
1833
+ ? html`<i class="leading-icon" part="leading-icon"
1834
+ ><moni-icon name="${this.icon}"></moni-icon
1835
+ ></i>`
1836
+ : nothing;
1837
+
1838
+ const trailing = this.loading
1839
+ ? html`<i class="trailing-icon" part="trailing-icon"
1840
+ ><moni-progress
1841
+ variant="circular"
1842
+ indeterminate
1843
+ size="small"
1844
+ style="inline-size: 1.25rem; block-size: 1.25rem; color: currentColor;"
1845
+ ></moni-progress
1846
+ ></i>`
1847
+ : html`<i
1848
+ class="trailing-icon"
1849
+ part="trailing-icon"
1850
+ @click=${this._toggleDropdown}
1851
+ ><moni-icon name="${this.trailingIcon}"></moni-icon
1852
+ ></i>`;
1853
+
1854
+ const hasGroups = this._parsedOptions.some((node) => node.type === "group");
1855
+ const usesFlyoutSubmenus =
1856
+ hasGroups && !this._searchQuery && !this._useInlineCategories;
1857
+ const menuClasses = {
1858
+ "dropdown-menu": true,
1859
+ open: this._open,
1860
+ scrollable: !usesFlyoutSubmenus,
1861
+ searching: Boolean(this._searchQuery),
1862
+ "inline-categories": this._useInlineCategories,
1863
+ "placement-top": this._actualPlacement === "top",
1864
+ "placement-bottom": this._actualPlacement === "bottom",
1865
+ "placement-left": this._actualPlacement === "left",
1866
+ "placement-right": this._actualPlacement === "right",
1867
+ };
1868
+
1869
+ return html`<div class=${classMap(fieldClasses)} part="field">
1870
+ ${leading}
1871
+ <input
1872
+ id="input"
1873
+ type="text"
1874
+ part="input"
1875
+ .value=${displayValue}
1876
+ ?readonly=${!this.searchable || this.sheet}
1877
+ ?disabled=${this.disabled}
1878
+ inputmode=${ifDefined(!this.searchable || this.sheet ? "none" : undefined)}
1879
+ placeholder=${this.placeholder || ""}
1880
+ class=${isActive ? "active" : ""}
1881
+ @click=${this._onInputClick}
1882
+ @input=${this._onSearchInput}
1883
+ @keydown=${this._onKeyDown}
1884
+ />
1885
+ ${
1886
+ this.label
1887
+ ? html`<label
1888
+ for="input"
1889
+ part="label"
1890
+ class=${classMap({ active: isActive })}
1891
+ >${this.label}</label
1892
+ >`
1893
+ : nothing
1894
+ }
1895
+ ${trailing}
1896
+ ${
1897
+ this.sheet
1898
+ ? html`
1899
+ <div
1900
+ class="sheet-backdrop ${this._open ? "open" : ""}"
1901
+ @click=${this._closeDropdown}
1902
+ >
1903
+ <div
1904
+ class="sheet-drawer ${this._open ? "open" : ""}"
1905
+ @click=${(e: Event) => e.stopPropagation()}
1906
+ >
1907
+ <div class="sheet-handle"></div>
1908
+ ${this.label ? html`<div class="sheet-title">${this.label}</div>` : nothing}
1909
+ ${
1910
+ this.searchable
1911
+ ? html`
1912
+ <div class="sheet-search-wrapper">
1913
+ <input
1914
+ type="text"
1915
+ class="sheet-search-input"
1916
+ .value=${this._searchQuery}
1917
+ placeholder="Buscar..."
1918
+ @input=${this._onSearchInput}
1919
+ />
1920
+ </div>
1921
+ `
1922
+ : nothing
1923
+ }
1924
+ <ul class="sheet-options-list">
1925
+ ${this._renderOptionsList(filtered)}
1926
+ </ul>
1927
+ </div>
1928
+ </div>
1929
+ `
1930
+ : html`
1931
+ <ul
1932
+ class=${classMap(menuClasses)}
1933
+ part="menu"
1934
+ popover=${this.positioning === "body" ? "manual" : nothing}
1935
+ style=${this._getMenuStyle()}
1936
+ >
1937
+ ${this._renderOptionsList(filtered)}
1938
+ </ul>
1939
+ `
1940
+ }
1941
+
1942
+ <slot style="display: none;" @slotchange=${this._onSlotChange}></slot>
1943
+
1944
+ ${
1945
+ this.error
1946
+ ? html`<output part="helper" class="invalid"
1947
+ >${this.errorText || this.helper}</output
1948
+ >`
1949
+ : this.helper
1950
+ ? html`<output part="helper">${this.helper}</output>`
1951
+ : nothing
1952
+ }
1953
+ </div>`;
1954
+ }
1955
+ }
1956
+
1957
+ declare global {
1958
+ interface HTMLElementTagNameMap {
1959
+ "moni-select": MoniSelect;
1960
+ }
1961
+ }
1962
+
1963
+ export default MoniSelect;