@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,234 +1,238 @@
1
- /**
2
- * @file components/moni-morph-modal.ts
3
- * @package @moni-labs/moni-ui
4
- * @license MIT
5
- * @contributors Moni Labs & Contributors
6
- */
7
-
8
- import { html, css } from 'lit';
9
- import { customElement, property, query, state } from 'lit/decorators.js';
10
- import { MoniElement, sharedStyles } from './_base/index.js';
11
- import { gsap } from 'gsap';
12
- import { Flip } from 'gsap/Flip';
13
- import { CustomEase } from 'gsap/CustomEase';
14
- import './moni-icon.js';
15
-
16
- gsap.registerPlugin(Flip, CustomEase);
17
-
18
- CustomEase.create('morph-open', '0.15, 0.85, 0.2, 1');
19
- CustomEase.create('morph-close', '0.4, 0, 0.2, 1');
20
-
21
-
22
- type Placement =
23
- | 'center'
24
- | 'top'
25
- | 'top-start'
26
- | 'top-end'
27
- | 'bottom'
28
- | 'bottom-start'
29
- | 'bottom-end'
30
- | 'left'
31
- | 'left-start'
32
- | 'left-end'
33
- | 'right'
34
- | 'right-start'
35
- | 'right-end'
36
- | 'viewport-center'
37
- | 'viewport-top'
38
- | 'viewport-bottom'
39
- | 'viewport-left'
40
- | 'viewport-right'
41
- | 'viewport-top-left'
42
- | 'viewport-top-right'
43
- | 'viewport-bottom-left'
44
- | 'viewport-bottom-right';
45
-
46
- /**
47
- * Estado interno de coordenadas y estilos del objetivo de morphing antes de la animación.
48
- *
49
- * @internal
50
- */
51
- interface TargetState {
52
- rect: DOMRect;
53
- backgroundColor: string;
54
- color: string;
55
- borderRadius: string;
56
- boxShadow: string;
57
- }
58
-
59
- /**
60
- * Referencias a los elementos internos de icono y texto animados durante el morphing.
61
- *
62
- * @internal
63
- */
64
- interface MorphElements {
65
- text: HTMLElement | null;
66
- icon: HTMLElement | null;
67
- }
68
-
69
- /**
70
- * Componente Material Design 3 Morph Modal.
71
- *
72
- * Un diálogo altamente interactivo que usa animaciones GSAP FLIP (First, Last, Invert, Play)
73
- * para "transformar" (morph) sin problemas cualquier elemento clickeado en la página en una superficie
74
- * modal completa, y transformarlo de vuelta cuando se cierra.
75
- *
76
- * **Coreografía de movimiento:**
77
- * Este componente implementa el patrón complejo de transformación de contenedor de M3. Cuando
78
- * se activa, el elemento origen se expande visualmente hacia el modal. El color de fondo,
79
- * el radio del borde y la tipografía del modal se desvanecen y se funden
80
- * perfectamente con el elemento de origen.
81
- *
82
- * **Mecanismo de activación:**
83
- * Los consumidores deben proporcionar la propiedad `triggerEvent` que contenga el `Event`
84
- * de puntero/clic original que inició la acción de apertura. El componente extrae
85
- * el `event.target` (o usa `clientX/Y` como respaldo) para determinar las coordenadas
86
- * de origen exactas para la animación GSAP FLIP.
87
- *
88
- * **Comportamiento del diálogo:**
89
- * Internamente, utiliza el elemento nativo `<dialog>`. Atrapa el foco, soporta
90
- * `Escape` para cerrar, y maneja clics en el fondo/scrim (que activan la
91
- * animación de morphing inverso antes de cerrar realmente el diálogo nativo).
92
- *
93
- * @example
94
- * ```html
95
- * <moni-morph-modal id="myModal" title="Detalles">
96
- * <p>Este modal se transformó desde el botón que acabas de clickear.</p>
97
- * </moni-morph-modal>
98
- *
99
- * <moni-button id="openBtn">Abrir Detalles</moni-button>
100
- *
101
- * <script>
102
- * const modal = document.getElementById('myModal');
103
- * document.getElementById('openBtn').addEventListener('click', (e) => {
104
- * modal.triggerEvent = e; // Pasa el evento para que sepa desde dónde transformarse
105
- * modal.open = true;
106
- * });
107
- * </script>
108
- * ```
109
- *
110
- * @slot default - El contenido del cuerpo principal del modal.
111
- * @slot header - Contenido de encabezado personalizado (sobrescribe el atributo `title`).
112
- * @slot footer - Botones de acción mostrados en la parte inferior derecha.
113
- */
114
-
115
- const litBool = {
116
- fromAttribute: (value: string | null) => value !== 'false' && value !== null,
117
- toAttribute: (value: boolean) => (value ? '' : null)
118
- };
119
-
120
- @customElement('moni-morph-modal')
121
- export class MoniMorphModal extends MoniElement {
122
- /**
123
- * Selector del elemento disparador desde el cual transformarse (ej. '#fab' o '.my-button').
124
- * Si se omite, el modal aún puede activarse programáticamente estableciendo `open = true`.
125
- * @type {string}
126
- */
127
- @property({ reflect: true }) target = '';
128
-
129
- /**
130
- * Controla el estado abierto/cerrado del modal.
131
- * Puede establecerse directamente, o se gestiona internamente cuando se hace clic en el elemento `target`.
132
- * @type {boolean}
133
- */
134
- @property({ type: Boolean, reflect: true, converter: litBool }) open = false;
135
-
136
- /**
137
- * Si es true, renderiza un fondo y atrapa el foco, actuando como un verdadero diálogo modal.
138
- * @type {boolean}
139
- * @default true
140
- */
141
- @property({ type: Boolean, reflect: true, converter: litBool }) modal = true;
142
-
143
- /**
144
- * Posición preferida del modal en relación con la ventana gráfica.
145
- * @type {Placement}
146
- * @default 'center'
147
- */
148
- @property({ reflect: true }) placement: Placement = 'center';
149
-
150
- /**
151
- * El ancho del modal cuando está completamente expandido.
152
- * @type {string}
153
- * @default '22rem'
154
- */
155
- @property({ reflect: true, attribute: 'expanded-width' }) expandedWidth =
156
- '22rem';
157
-
158
- /**
159
- * La altura del modal cuando está completamente expandido.
160
- * @type {string}
161
- * @default '18rem'
162
- */
163
- @property({ reflect: true, attribute: 'expanded-height' }) expandedHeight =
164
- '18rem';
165
-
166
- /**
167
- * Si es true, hacer clic fuera del modal lo cerrará.
168
- * @type {boolean}
169
- * @default true
170
- */
171
- @property({ type: Boolean, reflect: true, attribute: 'close-on-click-outside', converter: litBool })
172
- closeOnClickOutside = true;
173
-
174
- /**
175
- * Si es true, presionar la tecla Escape cerrará el modal.
176
- * @type {boolean}
177
- * @default true
178
- */
179
- @property({ type: Boolean, reflect: true, attribute: 'close-on-esc', converter: litBool })
180
- closeOnEsc = true;
181
-
182
- /**
183
- * Si es true, muestra un botón de icono de cierre por defecto en el encabezado.
184
- * @type {boolean}
185
- */
186
- @property({ type: Boolean, reflect: true, attribute: 'show-close-button', converter: litBool })
187
- showCloseButton = false;
188
-
189
- /**
190
- * Si es true, intenta fundir/transformar una etiqueta del disparador hacia el encabezado del modal.
191
- * @type {boolean}
192
- */
193
- @property({ type: Boolean, reflect: true, attribute: 'morph-label', converter: litBool })
194
- morphLabel = false;
195
-
196
- /**
197
- * Un selector CSS personalizado utilizado para encontrar el elemento de etiqueta específico dentro del target a transformar.
198
- * @type {string}
199
- */
200
- @property({ reflect: true, attribute: 'morph-label-selector' })
201
- morphLabelSelector = '';
202
-
203
- /**
204
- * Si es true, renderiza un fondo atenuado detrás del modal.
205
- * @type {boolean}
206
- * @default true
207
- */
208
- @property({ type: Boolean, reflect: true, attribute: 'has-backdrop', converter: litBool })
209
- hasBackdrop = true;
210
-
211
- /**
212
- * Si es true, oculta el elemento disparador (target) mientras el modal está abierto.
213
- * @type {boolean}
214
- */
215
- @property({ type: Boolean, reflect: true, attribute: 'hide-target', converter: litBool })
216
- hideTarget = false;
217
-
218
- /**
219
- * Si es true, fuerza al modal a cubrir visualmente la ubicación original del target antes de expandirse.
220
- * @type {boolean}
221
- */
222
- @property({ type: Boolean, reflect: true, attribute: 'cover-target', converter: litBool })
223
- coverTarget = false;
224
-
225
- /**
226
- * Si es true, el modal calcula automáticamente su tamaño final basándose en el contenido interno (ignora expandedWidth y expandedHeight).
227
- * @type {boolean}
228
- */
1
+ /**
2
+ * @file components/moni-morph-modal.ts
3
+ * @package @moni-labs/moni-ui
4
+ * @license MIT
5
+ * @contributors Moni Labs & Contributors
6
+ */
7
+
8
+ import { html, css } from 'lit';
9
+ import { customElement, property, query, state } from 'lit/decorators.js';
10
+ import { MoniElement, sharedStyles } from './_base/index.js';
11
+ import { gsap } from 'gsap';
12
+ import { Flip } from 'gsap/Flip';
13
+ import { CustomEase } from 'gsap/CustomEase';
14
+ import './moni-icon.js';
15
+
16
+ gsap.registerPlugin(Flip, CustomEase);
17
+
18
+ CustomEase.create('morph-open', '0.15, 0.85, 0.2, 1');
19
+ CustomEase.create('morph-close', '0.4, 0, 0.2, 1');
20
+
21
+
22
+ type Placement =
23
+ | 'center'
24
+ | 'top'
25
+ | 'top-start'
26
+ | 'top-end'
27
+ | 'bottom'
28
+ | 'bottom-start'
29
+ | 'bottom-end'
30
+ | 'left'
31
+ | 'left-start'
32
+ | 'left-end'
33
+ | 'right'
34
+ | 'right-start'
35
+ | 'right-end'
36
+ | 'viewport-center'
37
+ | 'viewport-top'
38
+ | 'viewport-bottom'
39
+ | 'viewport-left'
40
+ | 'viewport-right'
41
+ | 'viewport-top-left'
42
+ | 'viewport-top-right'
43
+ | 'viewport-bottom-left'
44
+ | 'viewport-bottom-right';
45
+
46
+ /**
47
+ * Estado interno de coordenadas y estilos del objetivo de morphing antes de la animación.
48
+ *
49
+ * @internal
50
+ */
51
+ interface TargetState {
52
+ rect: DOMRect;
53
+ backgroundColor: string;
54
+ color: string;
55
+ borderRadius: string;
56
+ boxShadow: string;
57
+ }
58
+
59
+ /**
60
+ * Referencias a los elementos internos de icono y texto animados durante el morphing.
61
+ *
62
+ * @internal
63
+ */
64
+ interface MorphElements {
65
+ text: HTMLElement | null;
66
+ icon: HTMLElement | null;
67
+ }
68
+
69
+ /**
70
+ * Componente Material Design 3 Morph Modal.
71
+ *
72
+ * Un diálogo altamente interactivo que usa animaciones GSAP FLIP (First, Last, Invert, Play)
73
+ * para "transformar" (morph) sin problemas cualquier elemento clickeado en la página en una superficie
74
+ * modal completa, y transformarlo de vuelta cuando se cierra.
75
+ *
76
+ * **Coreografía de movimiento:**
77
+ * Este componente implementa el patrón complejo de transformación de contenedor de M3. Cuando
78
+ * se activa, el elemento origen se expande visualmente hacia el modal. El color de fondo,
79
+ * el radio del borde y la tipografía del modal se desvanecen y se funden
80
+ * perfectamente con el elemento de origen.
81
+ *
82
+ * **Mecanismo de activación:**
83
+ * Los consumidores deben proporcionar la propiedad `triggerEvent` que contenga el `Event`
84
+ * de puntero/clic original que inició la acción de apertura. El componente extrae
85
+ * el `event.target` (o usa `clientX/Y` como respaldo) para determinar las coordenadas
86
+ * de origen exactas para la animación GSAP FLIP.
87
+ *
88
+ * **Comportamiento del diálogo:**
89
+ * Internamente, utiliza el elemento nativo `<dialog>`. Atrapa el foco, soporta
90
+ * `Escape` para cerrar, y maneja clics en el fondo/scrim (que activan la
91
+ * animación de morphing inverso antes de cerrar realmente el diálogo nativo).
92
+ *
93
+ * @example
94
+ * ```html
95
+ * <moni-morph-modal id="myModal" title="Detalles">
96
+ * <p>Este modal se transformó desde el botón que acabas de clickear.</p>
97
+ * </moni-morph-modal>
98
+ *
99
+ * <moni-button id="openBtn">Abrir Detalles</moni-button>
100
+ *
101
+ * <script>
102
+ * const modal = document.getElementById('myModal');
103
+ * document.getElementById('openBtn').addEventListener('click', (e) => {
104
+ * modal.triggerEvent = e; // Pasa el evento para que sepa desde dónde transformarse
105
+ * modal.open = true;
106
+ * });
107
+ * </script>
108
+ * ```
109
+ *
110
+ * @slot default - El contenido del cuerpo principal del modal.
111
+ * @slot header - Contenido de encabezado personalizado (sobrescribe el atributo `title`).
112
+ * @slot footer - Botones de acción mostrados en la parte inferior derecha.
113
+ */
114
+
115
+ const litBool = {
116
+ fromAttribute: (value: string | null) => value !== 'false' && value !== null,
117
+ toAttribute: (value: boolean) => (value ? '' : null)
118
+ };
119
+
120
+ @customElement('moni-morph-modal')
121
+ export class MoniMorphModal extends MoniElement {
122
+ /**
123
+ * Selector del elemento disparador desde el cual transformarse (ej. '#fab' o '.my-button').
124
+ * Si se omite, el modal aún puede activarse programáticamente estableciendo `open = true`.
125
+ * @type {string}
126
+ */
127
+ @property({ reflect: true }) target = '';
128
+
129
+ /**
130
+ * Controla el estado abierto/cerrado del modal.
131
+ * Puede establecerse directamente, o se gestiona internamente cuando se hace clic en el elemento `target`.
132
+ * @type {boolean}
133
+ */
134
+ @property({ type: Boolean, reflect: true, converter: litBool }) open = false;
135
+
136
+ /**
137
+ * Si es true, renderiza un fondo y atrapa el foco, actuando como un verdadero diálogo modal.
138
+ * @type {boolean}
139
+ * @default true
140
+ */
141
+ @property({ type: Boolean, reflect: true, converter: litBool }) modal = true;
142
+
143
+ /**
144
+ * Posición preferida del modal en relación con la ventana gráfica.
145
+ * @type {Placement}
146
+ * @default 'center'
147
+ */
148
+ @property({ reflect: true }) placement: Placement = 'center';
149
+
150
+ /**
151
+ * El ancho del modal cuando está completamente expandido.
152
+ * @type {string}
153
+ * @default '22rem'
154
+ */
155
+ @property({ reflect: true, attribute: 'expanded-width' }) expandedWidth =
156
+ '22rem';
157
+
158
+ /**
159
+ * La altura del modal cuando está completamente expandido.
160
+ * @type {string}
161
+ * @default '18rem'
162
+ */
163
+ @property({ reflect: true, attribute: 'expanded-height' }) expandedHeight =
164
+ '18rem';
165
+
166
+ /**
167
+ * Si es true, hacer clic fuera del modal lo cerrará.
168
+ * @type {boolean}
169
+ * @default true
170
+ */
171
+ @property({ type: Boolean, reflect: true, attribute: 'close-on-click-outside', converter: litBool })
172
+ closeOnClickOutside = true;
173
+
174
+ /**
175
+ * Si es true, presionar la tecla Escape cerrará el modal.
176
+ * @type {boolean}
177
+ * @default true
178
+ */
179
+ @property({ type: Boolean, reflect: true, attribute: 'close-on-esc', converter: litBool })
180
+ closeOnEsc = true;
181
+
182
+ /**
183
+ * Si es true, muestra un botón de icono de cierre por defecto en el encabezado.
184
+ * @type {boolean}
185
+ */
186
+ @property({ type: Boolean, reflect: true, attribute: 'show-close-button', converter: litBool })
187
+ showCloseButton = false;
188
+
189
+ /**
190
+ * Si es true, intenta fundir/transformar una etiqueta del disparador hacia el encabezado del modal.
191
+ * @type {boolean}
192
+ */
193
+ @property({ type: Boolean, reflect: true, attribute: 'morph-label', converter: litBool })
194
+ morphLabel = false;
195
+
196
+ /**
197
+ * Un selector CSS personalizado utilizado para encontrar el elemento de etiqueta específico dentro del target a transformar.
198
+ * @type {string}
199
+ */
200
+ @property({ reflect: true, attribute: 'morph-label-selector' })
201
+ morphLabelSelector = '';
202
+
203
+ /**
204
+ * Si es true, renderiza un fondo atenuado detrás del modal.
205
+ * @type {boolean}
206
+ * @default true
207
+ */
208
+ @property({ type: Boolean, reflect: true, attribute: 'has-backdrop', converter: litBool })
209
+ hasBackdrop = true;
210
+
211
+ /**
212
+ * Si es true, oculta el elemento disparador (target) mientras el modal está abierto.
213
+ * @type {boolean}
214
+ */
215
+ @property({ type: Boolean, reflect: true, attribute: 'hide-target', converter: litBool })
216
+ hideTarget = false;
217
+
218
+ /**
219
+ * Si es true, fuerza al modal a cubrir visualmente la ubicación original del target antes de expandirse.
220
+ * @type {boolean}
221
+ */
222
+ @property({ type: Boolean, reflect: true, attribute: 'cover-target', converter: litBool })
223
+ coverTarget = false;
224
+
225
+ /**
226
+ * Si es true, el modal calcula automáticamente su tamaño final basándose en el contenido interno (ignora expandedWidth y expandedHeight).
227
+ * @type {boolean}
228
+ */
229
229
  @property({ type: Boolean, reflect: true, attribute: 'auto-size', converter: litBool })
230
230
  autoSize = false;
231
231
 
232
+ /** Ajusta sólo la altura al contenido, sin superar expanded-height. */
233
+ @property({ type: Boolean, reflect: true, attribute: 'auto-height', converter: litBool })
234
+ autoHeight = false;
235
+
232
236
  /** Fuerza al modal expandido a ocupar todo el viewport. */
233
237
  @property({ type: Boolean, reflect: true, converter: litBool })
234
238
  fullscreen = false;
@@ -240,1674 +244,1734 @@ export class MoniMorphModal extends MoniElement {
240
244
  /** Ancho máximo del viewport, en píxeles, para aplicar responsive-fullscreen. */
241
245
  @property({ type: Number, reflect: true, attribute: 'fullscreen-breakpoint' })
242
246
  fullscreenBreakpoint = 600;
243
-
244
- /**
245
- * Si es true, aplica un efecto de desenfoque (blur) al contenido durante la animación de entrada y salida.
246
- * @type {boolean}
247
- */
248
- @property({ type: Boolean, reflect: true, attribute: 'blur-content', converter: litBool })
249
- blurContent = false;
250
-
251
- /** Muestra en consola el origen, geometría y estrategia usada por cada morph. */
252
- @property({ type: Boolean, reflect: true, converter: litBool })
253
- debug = false;
254
-
255
- @query('.panel') private _panel!: HTMLDivElement;
256
- @query('.panel-inner') private _inner!: HTMLDivElement;
257
- @query('.backdrop') private _backdrop!: HTMLDivElement;
258
- @query('header') private _header!: HTMLElement;
259
-
260
- @state() private _hasHeader = false;
261
- @state() private _hasFooter = false;
262
- @state() private _hasTriggerLabel = false;
263
-
264
- private _targetEl: HTMLElement | null = null;
265
- private _isAnimating = false;
266
- private _visible = false;
267
- private _handlingOpenChange = false;
268
- private _zIndex = 100;
269
- private _morphEls: MorphElements = { text: null, icon: null };
270
- private _targetSnapshot: HTMLElement | null = null;
247
+
248
+ /**
249
+ * Si es true, aplica un efecto de desenfoque (blur) al contenido durante la animación de entrada y salida.
250
+ * @type {boolean}
251
+ */
252
+ @property({ type: Boolean, reflect: true, attribute: 'blur-content', converter: litBool })
253
+ blurContent = false;
254
+
255
+ /** Muestra en consola el origen, geometría y estrategia usada por cada morph. */
256
+ @property({ type: Boolean, reflect: true, converter: litBool })
257
+ debug = false;
258
+
259
+ @query('.panel') private _panel!: HTMLDivElement;
260
+ @query('.panel-inner') private _inner!: HTMLDivElement;
261
+ @query('.backdrop') private _backdrop!: HTMLDivElement;
262
+ @query('header') private _header!: HTMLElement;
263
+
264
+ @state() private _hasHeader = false;
265
+ @state() private _hasFooter = false;
266
+ @state() private _hasTriggerLabel = false;
267
+
268
+ private _targetEl: HTMLElement | null = null;
269
+ private _isAnimating = false;
270
+ private _visible = false;
271
+ private _handlingOpenChange = false;
272
+ private _zIndex = 100;
273
+ private _morphEls: MorphElements = { text: null, icon: null };
274
+ private _targetSnapshot: HTMLElement | null = null;
271
275
  private _originFallbacks: HTMLElement[] = [];
272
276
  private static _openStack: MoniMorphModal[] = [];
273
277
  private _targetStylesCache = new Map<HTMLElement, { opacity: string; color: string }>();
274
-
275
- /**
276
- * Handler encapsulado para el evento click en el trigger.
277
- * Dispara la animación de apertura.
278
- */
279
- private _onTargetClick = () => this.show();
280
- /**
281
- * Handler encapsulado para interceptar teclas a nivel global (ej: Escape).
282
- */
283
- private _onDocKeydown = (e: KeyboardEvent) => this._handleDocKeydown(e);
284
- /**
285
- * Handler de clics globales para detectar interacciones fuera del modal (Click outside).
286
- * Sólo cierra el modal si `closeOnClickOutside` es true y el usuario no hizo clic
287
- * dentro de las dimensiones del panel animado ni del botón original.
288
- */
289
- private _onDocClick = (e: MouseEvent) => {
290
- if (!this.open || !this._visible || !this.closeOnClickOutside) return;
291
- if (e.composedPath().includes(this._panel)) return;
292
- if (this._targetEl && e.composedPath().includes(this._targetEl)) return;
293
- this.hide();
294
- };
295
-
296
- override connectedCallback(): void {
297
- super.connectedCallback();
298
- this._resolveTarget();
299
- document.addEventListener('keydown', this._onDocKeydown);
300
- document.addEventListener('click', this._onDocClick);
301
- }
302
-
303
- override disconnectedCallback(): void {
304
- document.removeEventListener('keydown', this._onDocKeydown);
305
- document.removeEventListener('click', this._onDocClick);
306
- if (this._targetEl) {
307
- this._targetEl.removeEventListener('click', this._onTargetClick);
308
- }
309
- const idx = MoniMorphModal._openStack.indexOf(this);
310
- if (idx > -1) MoniMorphModal._openStack.splice(idx, 1);
311
- this.style.removeProperty('z-index');
312
- this.style.removeProperty('--_z-index');
313
- this.style.removeProperty('--_backdrop-duration');
278
+ private static _scrollLockOwners = new Set<MoniMorphModal>();
279
+ private static _scrollLockSnapshot: {
280
+ documentOverflow: string;
281
+ bodyOverflow: string;
282
+ bodyOverscrollBehavior: string;
283
+ } | null = null;
284
+
285
+ /**
286
+ * Handler encapsulado para el evento click en el trigger.
287
+ * Dispara la animación de apertura.
288
+ */
289
+ private _onTargetClick = () => this.show();
290
+ /**
291
+ * Handler encapsulado para interceptar teclas a nivel global (ej: Escape).
292
+ */
293
+ private _onDocKeydown = (e: KeyboardEvent) => this._handleDocKeydown(e);
294
+ /**
295
+ * Handler de clics globales para detectar interacciones fuera del modal (Click outside).
296
+ * Sólo cierra el modal si `closeOnClickOutside` es true y el usuario no hizo clic
297
+ * dentro de las dimensiones del panel animado ni del botón original.
298
+ */
299
+ private _onDocClick = (e: MouseEvent) => {
300
+ if (!this.open || !this._visible || !this.closeOnClickOutside) return;
301
+ if (e.composedPath().includes(this._panel)) return;
302
+ if (this._targetEl && e.composedPath().includes(this._targetEl)) return;
303
+ this.hide();
304
+ };
305
+
306
+ override connectedCallback(): void {
307
+ super.connectedCallback();
308
+ this._resolveTarget();
309
+ document.addEventListener('keydown', this._onDocKeydown);
310
+ document.addEventListener('click', this._onDocClick);
311
+ }
312
+
313
+ override disconnectedCallback(): void {
314
+ document.removeEventListener('keydown', this._onDocKeydown);
315
+ document.removeEventListener('click', this._onDocClick);
316
+ if (this._targetEl) {
317
+ this._targetEl.removeEventListener('click', this._onTargetClick);
318
+ }
319
+ const idx = MoniMorphModal._openStack.indexOf(this);
320
+ if (idx > -1) MoniMorphModal._openStack.splice(idx, 1);
321
+ this.style.removeProperty('z-index');
322
+ this.style.removeProperty('--_z-index');
323
+ this.style.removeProperty('--_backdrop-duration');
314
324
  this.style.removeProperty('--_backdrop-ease');
325
+ this._releaseDocumentScrollLock();
315
326
  this._cleanupMorphElements();
316
- this._restoreTargetContent();
317
- super.disconnectedCallback();
318
- }
319
-
320
- /**
321
- * Hook del ciclo de vida reactivo (Lit).
322
- * Observa cambios asíncronos en el atributo `target` para re-bindear el disparador,
323
- * y reacciona a cambios programáticos de la propiedad `open` para ejecutar
324
- * imperativamente la animación de apertura (`show`) o cierre (`hide`).
325
- */
326
- override updated(changed: Map<string, unknown>) {
327
- super.updated(changed);
328
- if (changed.has('target')) {
329
- this._resolveTarget();
330
- }
327
+ this._restoreTargetContent();
328
+ super.disconnectedCallback();
329
+ }
330
+
331
+ /**
332
+ * Hook del ciclo de vida reactivo (Lit).
333
+ * Observa cambios asíncronos en el atributo `target` para re-bindear el disparador,
334
+ * y reacciona a cambios programáticos de la propiedad `open` para ejecutar
335
+ * imperativamente la animación de apertura (`show`) o cierre (`hide`).
336
+ */
337
+ override updated(changed: Map<string, unknown>) {
338
+ super.updated(changed);
339
+ if (changed.has('target')) {
340
+ this._resolveTarget();
341
+ }
331
342
  if (changed.has('open') && !this._handlingOpenChange) {
332
343
  if (this.open) this.show();
333
344
  else this.hide();
334
345
  }
335
- }
336
-
337
- /**
338
- * Busca en el DOM el elemento que dispara el modal (el trigger) basándose en la propiedad `target`.
339
- * Establece el listener de click que iniciará la animación de morphing.
340
- */
341
- private _resolveTarget(): void {
342
- if (!this.target) return;
343
-
344
- const el = document.querySelector<HTMLElement>(this.target);
345
- if (!el) {
346
- // Es normal que no se encuentre el target si está condicionado por un #if en Svelte
347
- // Se intentará resolver nuevamente al llamar a show()
348
- return;
349
- }
350
-
351
- // Si el target cambió dinámicamente, limpiamos el listener anterior para evitar memory leaks
352
- if (this._targetEl && this._targetEl !== el) {
353
- this._targetEl.removeEventListener('click', this._onTargetClick);
346
+ if ((changed.has('hasBackdrop') || changed.has('fullscreen') || changed.has('responsiveFullscreen') || changed.has('fullscreenBreakpoint')) && (this.open || this._visible)) {
347
+ this._syncDocumentScrollLock();
354
348
  }
355
- this._targetEl = el;
356
- this._targetEl.addEventListener('click', this._onTargetClick);
357
349
  }
358
350
 
359
- /**
360
- * Dispara la apertura del modal y coordina la animación de FLIP (First, Last, Invert, Play) / Morphing.
361
- * 1. Oculta visualmente el target original.
362
- * 2. Posiciona el modal colapsado exactamente sobre el target original (mismas dimensiones, color, y radios).
363
- * 3. Expande fluidamente el modal a sus dimensiones finales mediante transformaciones CSS.
364
- */
365
- show(): void {
366
- // Prevenimos ejecuciones concurrentes si ya se está animando o está visible
367
- if (this._isAnimating || this._visible) return;
368
-
369
- if (!this._targetEl) this._resolveTarget();
370
- if (!this._targetEl) {
371
- console.warn('[moni-morph-modal] cannot show: target not found');
372
- return;
373
- }
374
- this._captureOriginFallbacks(this._targetEl);
375
-
376
- this._isAnimating = true;
377
-
378
- // Flag interno para evitar loops infinitos en el hook `updated()`
379
- this._handlingOpenChange = true;
380
- this.open = true;
381
- this._handlingOpenChange = false;
382
-
383
- // Apilamos el modal globalmente para manejar correctamente el z-index en modales anidados
384
- MoniMorphModal._openStack.push(this);
385
- this._zIndex = 1000 + MoniMorphModal._openStack.length;
386
-
387
- // Aplicamos variables CSS inline para gestionar las transiciones del backdrop y el modal
388
- this.style.zIndex = String(this._zIndex);
389
- this.style.setProperty('--_z-index', String(this._zIndex));
390
- this.style.setProperty('--_backdrop-duration', '0.38s');
391
- this.style.setProperty('--_backdrop-ease', 'cubic-bezier(0.15, 0.85, 0.2, 1)');
392
- if (this._backdrop) gsap.set(this._backdrop, { clearProps: 'opacity,transition' });
393
-
394
- // Fase "First": Ocultamos o cubrimos el trigger original según la configuración del desarrollador
395
- this._hideTargetContent();
396
-
397
- void this.updateComplete.then(async () => {
398
- if (!this._targetEl || !this._panel || !this._inner) {
399
- this._isAnimating = false;
400
- return;
401
- }
402
-
403
- // Añadimos clase para prevenir interacciones durante la animación
404
- this._panel.classList.add('is-animating');
405
-
406
- // Esperamos un frame para asegurar que el navegador aplicó el `display: block`
407
- // necesario antes de poder leer las dimensiones.
408
- await new Promise((resolve) => requestAnimationFrame(resolve));
409
-
410
- // 1. "First": Calculamos el estado visual actual del trigger
411
- const targetState = this._getTargetState(this._targetEl);
412
- this._debugMorph('open', this._targetEl, 'target', [this._targetEl]);
413
- const panel = this._panel;
414
-
415
- // 2. "Invert": Posicionamos el modal como un clon visual exacto del trigger.
416
- // Desactivamos la sombra y forzamos el color/borde para coincidir con el botón/FAB.
417
- this._applyRect(panel, this._toPanelCoordinateRect(targetState.rect));
418
- panel.style.backgroundColor = targetState.backgroundColor;
419
- panel.style.color = targetState.color;
420
- panel.style.borderRadius = targetState.borderRadius;
421
- panel.style.boxShadow = targetState.boxShadow;
422
- panel.style.zIndex = String(this._zIndex);
423
- panel.style.visibility = 'visible';
424
- panel.style.opacity = '1';
425
- panel.style.pointerEvents = 'auto';
426
-
427
- if (this._backdrop) {
428
- this._backdrop.style.zIndex = String(this._zIndex - 1);
429
- }
430
-
431
- // Limpiamos elementos fantasma previos si el usuario interactuó muy rápido
432
- this._cleanupMorphElements();
433
-
434
- // Respetamos la preferencia de sistema de animaciones reducidas por accesibilidad (a11y)
435
- const prefersReducedMotion = window.matchMedia(
436
- '(prefers-reduced-motion: reduce)'
437
- ).matches;
438
-
439
- // 3. Verificamos si debemos orquestar la animación compleja del label flotante cruzado
440
- let shouldMorph = this._shouldMorphLabel() && !prefersReducedMotion;
441
- const morphTargets: HTMLElement[] = [];
442
- let source: any = null;
443
- const headerWrapper = this.shadowRoot!.querySelector('.header-morph-wrapper') as HTMLElement;
444
-
445
- if (shouldMorph) {
446
- // Preparamos la estructura del header para que el texto real entre con un fade in
447
- this._animateLabelOpen();
448
-
449
- // Buscamos el ícono y el texto dentro del botón de origen (trigger)
450
- source = this._resolveMorphSource();
451
-
452
- // Si existe un nodo de texto, creamos un elemento flotante (fantasma) en el modal
453
- if (source.text) {
454
- const el = this._createMorphText(source.text.content, source.text.sourceEl);
455
- this._positionElement(el, source.text.rect);
456
- morphTargets.push(el);
457
- }
458
-
459
- // Hacemos lo mismo si el botón original contenía un icono
460
- if (source.icon) {
461
- const el = this._createMorphIcon(source.icon.element);
462
- this._positionElement(el, source.icon.rect);
463
- morphTargets.push(el);
464
- }
465
- if (morphTargets.length === 0) shouldMorph = false;
466
- }
467
-
468
- if (shouldMorph && headerWrapper) {
469
- // Ocultamos temporalmente el contenido final del header mientras animamos los elementos flotantes
470
- headerWrapper.style.opacity = '0';
471
- }
472
-
473
- // Capturamos el estado "First" del panel (tamaño pequeño de botón) y de los elementos flotantes
474
- const state = Flip.getState(panel, {
475
- props: 'backgroundColor,borderRadius,color,boxShadow'
476
- });
477
- const morphState = Flip.getState(morphTargets, {
478
- props: 'color,fontSize,fontWeight,lineHeight'
479
- });
480
-
481
- // Fase "Last": Calculamos las dimensiones expandidas del modal basándonos en la configuración
482
- let naturalSize: { width: number; height: number } | undefined;
483
- if (this.autoSize) {
484
- const prevW = panel.style.getPropertyValue('--_panel-width');
485
- const prevH = panel.style.getPropertyValue('--_panel-height');
486
- const prevPanelW = panel.style.width;
487
- const prevPanelH = panel.style.height;
488
-
489
- panel.style.width = 'auto';
490
- panel.style.height = 'auto';
491
- panel.style.setProperty('--_panel-width', 'max-content');
492
- panel.style.setProperty('--_panel-height', 'max-content');
493
-
494
- const bodyEl = this.shadowRoot!.querySelector('.body') as HTMLElement;
495
- const prevOverflow = bodyEl ? bodyEl.style.overflow : '';
496
- if (bodyEl) bodyEl.style.overflow = 'visible';
497
-
498
- // Forzamos al panel-inner a expandirse para medir su contenido real
499
- naturalSize = { width: this._inner.scrollWidth, height: this._inner.scrollHeight };
500
-
501
- panel.style.setProperty('--_panel-width', prevW);
502
- panel.style.setProperty('--_panel-height', prevH);
503
- panel.style.width = prevPanelW;
504
- panel.style.height = prevPanelH;
505
-
506
- if (bodyEl) bodyEl.style.overflow = prevOverflow;
507
- }
508
-
509
- const finalRect = this._computeFinalRect(targetState.rect, naturalSize);
510
- panel.style.setProperty('--_panel-width', `${finalRect.width}px`);
511
- panel.style.setProperty('--_panel-height', `${finalRect.height}px`);
512
- this._applyRect(panel, finalRect);
513
-
514
- // Removemos las forzadas de estilo del trigger para que herede las propias del modal
515
- panel.style.backgroundColor = '';
516
- panel.style.color = '';
517
- panel.style.borderRadius = '';
518
- panel.style.boxShadow = '';
519
-
520
- if (shouldMorph && source) {
521
- // Medimos dónde van a terminar el icono y el texto dentro del modal ya expandido
522
- const textRect = this._measureHeaderTextRect();
523
- const iconRect = this._measureHeaderIconRect();
524
- const headerComputed = window.getComputedStyle(this._header);
525
-
526
- if (this._morphEls.text && textRect) {
527
- // Movemos el texto flotante a su destino y le pedimos que adopte los estilos del header
528
- this._positionElement(this._morphEls.text, textRect);
529
- this._morphEls.text.style.color = headerComputed.color || 'var(--on-surface)';
530
- this._morphEls.text.style.fontSize = headerComputed.fontSize || 'inherit';
531
- this._morphEls.text.style.fontWeight = headerComputed.fontWeight || 'inherit';
532
- this._morphEls.text.style.lineHeight = headerComputed.lineHeight || 'inherit';
533
- }
534
-
535
- if (this._morphEls.icon && iconRect) {
536
- // Hacemos lo mismo con el icono
537
- this._positionElement(this._morphEls.icon, iconRect);
538
- this._morphEls.icon.style.color = headerComputed.color || 'var(--on-surface)';
539
- }
540
- }
541
-
542
- Flip.from(state, {
543
- targets: panel,
544
- duration: 0.38,
545
- ease: 'morph-open',
546
- zIndex: this._zIndex,
547
- onComplete: () => {
548
- this._isAnimating = false;
549
- this._visible = true;
550
- this._cleanupMorphElements();
551
- if (headerWrapper) headerWrapper.style.opacity = '1';
552
- }
553
- });
554
-
555
- if (morphTargets.length > 0) {
556
- if (this.blurContent) {
557
- gsap.set(morphTargets, { filter: 'blur(16px)' });
558
- }
559
- const flipConfig: any = {
560
- targets: morphTargets,
561
- duration: 0.38,
562
- ease: 'morph-open',
563
- zIndex: this._zIndex + 10
564
- };
565
- if (this.blurContent) {
566
- flipConfig.filter = 'blur(0px)';
567
- flipConfig.clearProps = 'filter';
568
- }
569
- Flip.from(morphState, flipConfig);
570
- }
571
-
572
- const body = this.shadowRoot!.querySelector('.body');
573
- const footer = this.shadowRoot!.querySelector('footer');
574
- const closeBtn = this.shadowRoot!.querySelector('.close-btn');
575
- const fadeTargets = [body, footer, closeBtn].filter(Boolean) as HTMLElement[];
576
-
577
- const initialBlur = this.blurContent ? 'blur(16px)' : 'blur(0px)';
578
- gsap.set(fadeTargets, { opacity: 0, y: 8, filter: initialBlur });
579
- gsap.fromTo(
580
- fadeTargets,
581
- { opacity: 0, y: 8, filter: initialBlur },
582
- {
583
- opacity: 1,
584
- y: 0,
585
- filter: 'blur(0px)',
586
- duration: 0.32,
587
- delay: 0.08,
588
- stagger: 0.04,
589
- ease: 'power2.out',
590
- onComplete: () => {
591
- gsap.set(fadeTargets, { clearProps: 'opacity,transform,filter' });
592
- this._panel.classList.remove('is-animating');
593
- }
594
- }
595
- );
596
- });
351
+ private _shouldLockDocumentScroll(): boolean {
352
+ return this.hasBackdrop || this._usesFullscreen();
597
353
  }
598
354
 
599
- /**
600
- * Abre el modal realizando el morph desde un elemento proporcionado directamente.
601
- * Útil cuando el trigger vive dentro de un Shadow DOM y no puede resolverse con `target`.
602
- */
603
- showFrom(target: HTMLElement): void {
604
- if (!target) return;
605
- this._targetEl = target;
606
- this.show();
355
+ private _syncDocumentScrollLock(): void {
356
+ if (this._shouldLockDocumentScroll()) this._acquireDocumentScrollLock();
357
+ else this._releaseDocumentScrollLock();
607
358
  }
608
359
 
609
- /** Redimensiona y reposiciona suavemente un modal que ya está abierto. */
610
- resizeTo(width: string, height: string, anchor: 'center' | 'top-left' = 'center'): void {
611
- this.expandedWidth = width;
612
- this.expandedHeight = height;
613
- if (!this._visible || !this._panel || !this._targetEl) return;
614
-
615
- let finalRect = this._computeFinalRect(this._targetEl.getBoundingClientRect());
616
- const transformedAncestor = this.parentElement?.closest<HTMLElement>('.lab-canvas, [data-morph-coordinate-space]');
617
- const transform = transformedAncestor ? getComputedStyle(transformedAncestor).transform : 'none';
618
- let scaleX = 1;
619
- let scaleY = 1;
620
- let ancestorRect: DOMRect | undefined;
621
- if (transformedAncestor && transform !== 'none') {
622
- const matrix = new DOMMatrix(transform);
623
- scaleX = Math.hypot(matrix.a, matrix.b) || 1;
624
- scaleY = Math.hypot(matrix.c, matrix.d) || 1;
625
- ancestorRect = transformedAncestor.getBoundingClientRect();
626
- }
627
- if (anchor === 'top-left') {
628
- const current = this._panel.getBoundingClientRect();
629
- let left = current.left;
630
- let top = current.top;
631
- if (ancestorRect) {
632
- left = (current.left - ancestorRect.left) / scaleX;
633
- top = (current.top - ancestorRect.top) / scaleY;
634
- }
635
- finalRect = new DOMRect(left, top, finalRect.width, finalRect.height);
636
- } else if (anchor === 'center' && ancestorRect) {
637
- const visualLeft = window.innerWidth / 2 - (finalRect.width * scaleX) / 2;
638
- const visualTop = window.innerHeight / 2 - (finalRect.height * scaleY) / 2;
639
- finalRect = new DOMRect(
640
- (visualLeft - ancestorRect.left) / scaleX,
641
- (visualTop - ancestorRect.top) / scaleY,
642
- finalRect.width,
643
- finalRect.height
644
- );
360
+ private _acquireDocumentScrollLock(): void {
361
+ if (MoniMorphModal._scrollLockOwners.has(this)) return;
362
+ if (MoniMorphModal._scrollLockOwners.size === 0) {
363
+ MoniMorphModal._scrollLockSnapshot = {
364
+ documentOverflow: document.documentElement.style.overflow,
365
+ bodyOverflow: document.body.style.overflow,
366
+ bodyOverscrollBehavior: document.body.style.overscrollBehavior
367
+ };
368
+ document.documentElement.style.overflow = 'hidden';
369
+ document.body.style.overflow = 'hidden';
370
+ document.body.style.overscrollBehavior = 'none';
645
371
  }
646
- this._panel.classList.add('is-animating');
647
- gsap.to(this._panel, {
648
- top: finalRect.top,
649
- left: finalRect.left,
650
- width: finalRect.width,
651
- height: finalRect.height,
652
- '--_panel-width': `${finalRect.width}px`,
653
- '--_panel-height': `${finalRect.height}px`,
654
- duration: 0.32,
655
- ease: 'morph-open',
656
- overwrite: true,
657
- onComplete: () => this._panel?.classList.remove('is-animating'),
658
- onInterrupt: () => this._panel?.classList.remove('is-animating')
659
- });
372
+ MoniMorphModal._scrollLockOwners.add(this);
660
373
  }
661
374
 
662
- /**
663
- * Solución a un caso borde de FLIP Animations:
664
- * Si el `.panel` original carece de un color de fondo sólido, la interpolación
665
- * de GSAP puede comportarse erráticamente. Este método "asciende" el árbol del DOM
666
- * hasta encontrar un padre con un color de fondo sólido y lo aplica, garantizando
667
- * que el modal no colapse de forma transparente de regreso hacia el gatillo.
668
- */
669
- private _ensureSolidPanelBackground() {
670
- if (!this._panel) return;
671
- let bg = window.getComputedStyle(this._panel).backgroundColor;
672
- if (this._isColorTransparent(bg)) {
673
- const solidBg = this._findSolidBackground(this._panel);
674
- if (solidBg) {
675
- this._panel.style.backgroundColor = solidBg;
676
- } else {
677
- // Aseguramos un color sólido para evitar bugs de animación FLIP hacia rgba(0,0,0,0).
678
- // NOTA: GSAP falla si el color inline es un string con var(). Usamos un fallback seguro.
679
- const fallback = getComputedStyle(document.body).getPropertyValue('--surface-container-high').trim();
680
- this._panel.style.backgroundColor = fallback || '#2d2d2d';
681
- }
375
+ private _releaseDocumentScrollLock(): void {
376
+ if (!MoniMorphModal._scrollLockOwners.delete(this) || MoniMorphModal._scrollLockOwners.size > 0) return;
377
+ const snapshot = MoniMorphModal._scrollLockSnapshot;
378
+ if (snapshot) {
379
+ document.documentElement.style.overflow = snapshot.documentOverflow;
380
+ document.body.style.overflow = snapshot.bodyOverflow;
381
+ document.body.style.overscrollBehavior = snapshot.bodyOverscrollBehavior;
682
382
  }
383
+ MoniMorphModal._scrollLockSnapshot = null;
683
384
  }
684
-
685
- hide(): void {
686
- if (this._isAnimating || !this._visible) return;
687
-
688
- this._isAnimating = true;
689
- this._panel.classList.add('is-animating');
690
-
691
- // Soluciona el caso donde el usuario no definió un fondo para .panel y lo aplicó en .body
692
- this._ensureSolidPanelBackground();
693
-
694
- this.style.setProperty('--_backdrop-duration', '0.3s');
695
- this.style.setProperty('--_backdrop-ease', 'cubic-bezier(0.4, 0, 0.2, 1)');
696
-
697
- const body = this.shadowRoot!.querySelector('.body');
698
- const footer = this.shadowRoot!.querySelector('footer');
699
- const closeBtn = this.shadowRoot!.querySelector('.close-btn');
700
- const headerWrapper = this.shadowRoot!.querySelector('.header-morph-wrapper') as HTMLElement;
701
- const fadeTargets = [body, footer, closeBtn, headerWrapper].filter(Boolean) as HTMLElement[];
702
-
703
- // El contenido pierde definición al retirarse, evitando un corte plano
704
- // antes de que la superficie comience su recorrido. `blurContent`
705
- // conserva la variante más expresiva para quienes desean mayor intensidad.
706
- const targetBlur = this.blurContent ? 'blur(12px)' : 'blur(6px)';
707
- gsap.fromTo(
708
- fadeTargets,
709
- { opacity: 1, filter: 'blur(0px)' },
710
- {
711
- opacity: 0,
712
- filter: targetBlur,
713
- duration: 0.12,
714
- ease: 'power2.in',
715
- onComplete: () => {
716
- const panel = this._panel;
717
- const closingTarget = this._resolveClosingTarget();
718
-
719
- this._cleanupMorphElements();
720
- const prefersReducedMotion = window.matchMedia(
721
- '(prefers-reduced-motion: reduce)'
722
- ).matches;
723
- // El cierre usa un único snapshot compuesto. La ruta histórica que
724
- // creaba `morph-text` y `morph-icon` por separado podía ejecutarse a
725
- // la vez que el snapshot y dejar etiquetas flotando sobre el modal.
726
- let shouldMorph = false;
727
- const morphTargets: HTMLElement[] = [];
728
- let source: any = null;
729
- let destinationMorph: { element: HTMLElement; rect: DOMRect; source: HTMLElement } | null = null;
730
-
731
- if (shouldMorph) {
732
- this._animateLabelClose();
733
- source = this._resolveMorphSource();
734
- const textRect = this._measureHeaderTextRect();
735
- const iconRect = this._measureHeaderIconRect();
736
- const headerComputed = window.getComputedStyle(this._header);
737
-
738
- if (source.text && textRect) {
739
- const el = this._createMorphText(source.text.content, source.text.sourceEl);
740
- this._positionElement(el, textRect);
741
- el.style.color = headerComputed.color || 'var(--on-surface)';
742
- el.style.fontSize = headerComputed.fontSize || 'inherit';
743
- el.style.fontWeight = headerComputed.fontWeight || 'inherit';
744
- el.style.lineHeight = headerComputed.lineHeight || 'inherit';
745
- morphTargets.push(el);
746
- }
747
-
748
- if (source.icon && iconRect) {
749
- const el = this._createMorphIcon(source.icon.element);
750
- this._positionElement(el, iconRect);
751
- el.style.color = headerComputed.color || 'var(--on-surface)';
752
- morphTargets.push(el);
753
- }
754
-
755
- if (morphTargets.length === 0) shouldMorph = false;
756
- }
757
-
758
- // Si el destino de cierre cambió, el contenido final debe ser una
759
- // representación completa del target (no solamente su icono). Esto
760
- // permite hacer morph hacia rich buttons, cards, imágenes y cualquier
761
- // otra superficie compuesta.
762
- if (closingTarget && !shouldMorph && !prefersReducedMotion) {
763
- const visualTarget = this._resolveVisualTarget(closingTarget);
764
- const destinationRect = visualTarget.getBoundingClientRect();
765
- if (destinationRect.width > 0 && destinationRect.height > 0) {
766
- // El contenido del trigger permanece oculto mientras el modal está
767
- // abierto. Lo restauramos únicamente durante la captura para que el
768
- // snapshot no herede opacity:0/color:transparent.
769
- if (closingTarget === this._targetEl) this._restoreTargetContent();
770
- const element = this._createTargetSnapshot(visualTarget, destinationRect);
771
- if (closingTarget === this._targetEl) this._hideTargetContent();
772
- element.style.opacity = '0';
773
- destinationMorph = { element, rect: destinationRect, source: visualTarget };
774
- }
775
- }
776
-
777
- if (shouldMorph && headerWrapper) {
778
- headerWrapper.style.opacity = '0';
779
- }
780
-
781
- const state = Flip.getState(panel, {
782
- props: 'backgroundColor,borderRadius,color,boxShadow'
783
- });
784
- const morphState = Flip.getState(morphTargets, {
785
- props: 'color,fontSize,fontWeight,lineHeight,opacity'
786
- });
787
-
788
- if (!closingTarget) {
789
- gsap.to(this._backdrop, {
790
- opacity: 0,
791
- duration: prefersReducedMotion ? 0.01 : 0.22,
792
- ease: 'power2.in'
793
- });
794
- gsap.to(panel, {
795
- autoAlpha: 0,
796
- scale: 0.92,
797
- duration: prefersReducedMotion ? 0.01 : 0.22,
798
- ease: 'power2.in',
799
- overwrite: true,
800
- onComplete: () => {
801
- gsap.set(panel, { clearProps: 'transform' });
802
- this._finishHide(fadeTargets, headerWrapper);
803
- }
804
- });
805
- return;
806
- }
807
-
808
- const targetState = this._getTargetState(closingTarget);
809
- if (destinationMorph) {
810
- this._runTargetMorphTimeline(
811
- panel,
812
- targetState,
813
- destinationMorph,
814
- fadeTargets,
815
- headerWrapper,
816
- prefersReducedMotion
817
- );
818
- return;
819
- }
820
-
821
- let naturalSize: { width: number; height: number } | undefined;
822
- if (this.autoSize) {
823
- const prevW = panel.style.getPropertyValue('--_panel-width');
824
- const prevH = panel.style.getPropertyValue('--_panel-height');
825
- const prevPanelW = panel.style.width;
826
- const prevPanelH = panel.style.height;
827
-
828
- panel.style.width = 'auto';
829
- panel.style.height = 'auto';
830
- panel.style.setProperty('--_panel-width', 'max-content');
831
- panel.style.setProperty('--_panel-height', 'max-content');
832
-
833
- const bodyEl = this.shadowRoot!.querySelector('.body') as HTMLElement;
834
- const prevOverflow = bodyEl ? bodyEl.style.overflow : '';
835
- if (bodyEl) bodyEl.style.overflow = 'visible';
836
-
837
- naturalSize = { width: this._inner.scrollWidth, height: this._inner.scrollHeight };
838
-
839
- panel.style.setProperty('--_panel-width', prevW);
840
- panel.style.setProperty('--_panel-height', prevH);
841
- panel.style.width = prevPanelW;
842
- panel.style.height = prevPanelH;
843
-
844
- if (bodyEl) bodyEl.style.overflow = prevOverflow;
845
- }
846
-
847
- const finalRect = this._computeFinalRect(targetState.rect, naturalSize);
848
- panel.style.setProperty('--_panel-width', `${finalRect.width}px`);
849
- panel.style.setProperty('--_panel-height', `${finalRect.height}px`);
850
-
851
- this._applyRect(panel, this._toPanelCoordinateRect(targetState.rect));
852
- panel.style.backgroundColor = targetState.backgroundColor;
853
- panel.style.color = targetState.color;
854
- panel.style.borderRadius = targetState.borderRadius;
855
- panel.style.boxShadow = targetState.boxShadow;
856
-
857
- if (shouldMorph && source) {
858
- if (this._morphEls.text && source.text) {
859
- this._positionElement(this._morphEls.text, source.text.rect);
860
- this._morphEls.text.style.color =
861
- window.getComputedStyle(source.text.sourceEl).color || 'inherit';
862
- this._morphEls.text.style.fontSize =
863
- window.getComputedStyle(source.text.sourceEl).fontSize || 'inherit';
864
- this._morphEls.text.style.fontWeight =
865
- window.getComputedStyle(source.text.sourceEl).fontWeight || 'inherit';
866
- this._morphEls.text.style.lineHeight =
867
- window.getComputedStyle(source.text.sourceEl).lineHeight || 'inherit';
868
- }
869
- if (this._morphEls.icon && source.icon) {
870
- this._positionElement(this._morphEls.icon, source.icon.rect);
871
- this._morphEls.icon.style.color =
872
- window.getComputedStyle(source.icon.element).color || 'inherit';
873
- }
874
- }
875
- Flip.from(state, {
876
- targets: panel,
877
- duration: 0.3,
878
- ease: 'morph-close',
879
- zIndex: this._zIndex,
880
- onComplete: () => this._finishHide(fadeTargets, headerWrapper)
881
- });
882
- gsap.to(this._backdrop, {
883
- opacity: 0,
884
- duration: 0.3,
885
- ease: 'power2.inOut'
886
- });
887
-
888
- if (morphTargets.length > 0) {
889
- if (this.blurContent) {
890
- gsap.set(morphTargets, { filter: 'blur(0px)' });
891
- }
892
- const flipConfig: any = {
893
- targets: morphTargets,
894
- duration: 0.3,
895
- ease: 'morph-close',
896
- zIndex: this._zIndex + 10
897
- };
898
- if (this.blurContent) {
899
- flipConfig.filter = 'blur(12px)';
900
- }
901
- Flip.from(morphState, flipConfig);
902
- }
903
- }
904
- }
905
- );
906
- }
907
-
908
- private _runTargetMorphTimeline(
909
- panel: HTMLElement,
910
- target: TargetState,
911
- destination: { element: HTMLElement; rect: DOMRect; source: HTMLElement },
912
- fadeTargets: HTMLElement[],
913
- headerWrapper: HTMLElement | null,
914
- prefersReducedMotion: boolean
915
- ): void {
916
- const current = this._toPanelCoordinateRect(panel.getBoundingClientRect());
917
- const targetRect = this._toPanelCoordinateRect(target.rect);
918
- const destinationRect = this._toPanelCoordinateRect(destination.rect);
919
- const destinationContent = destination.element;
920
- const activeTargetAnimations = destination.source.getAnimations?.({ subtree: true }) ?? [];
921
- const remainingTargetMotion = activeTargetAnimations.reduce((remaining, animation) => {
922
- const timing = animation.effect?.getComputedTiming();
923
- const endTime = typeof timing?.endTime === 'number' ? timing.endTime : 0;
924
- const currentTime = typeof animation.currentTime === 'number' ? animation.currentTime : 0;
925
- return Math.max(remaining, Math.max(0, endTime - currentTime) / 1000);
926
- }, 0);
927
- const morphDuration = Math.min(1.2, Math.max(0.46, remainingTargetMotion));
928
- const start = {
929
- x: current.left + current.width / 2,
930
- y: current.top + current.height / 2
931
- };
932
- const end = {
933
- x: targetRect.left + targetRect.width / 2,
934
- y: targetRect.top + targetRect.height / 2
935
- };
936
- const dx = end.x - start.x;
937
- const dy = end.y - start.y;
938
- const distance = Math.hypot(dx, dy) || 1;
939
- // En una Bézier cuadrática el desplazamiento máximo visible equivale
940
- // aproximadamente a la mitad del offset del control. 0.72 produce una
941
- // curva expresiva visible (~36% de la distancia) sin formar un bucle.
942
- const arc = Math.min(360, Math.max(48, distance * 0.72));
943
- const perpendicular = { x: -dy / distance, y: dx / distance };
944
- const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
945
- const viewportCenter = this._toPanelCoordinateRect(
946
- new DOMRect(window.innerWidth / 2, window.innerHeight / 2, 0, 0)
947
- );
948
- const candidates = [1, -1].map((direction) => ({
949
- x: midpoint.x + perpendicular.x * arc * direction,
950
- y: midpoint.y + perpendicular.y * arc * direction
951
- }));
952
- const centerDistance = (point: { x: number; y: number }) =>
953
- Math.abs(
954
- Math.abs(dy) >= Math.abs(dx)
955
- ? point.x - viewportCenter.x
956
- : point.y - viewportCenter.y
957
- );
958
- const control = centerDistance(candidates[0]) <= centerDistance(candidates[1])
959
- ? candidates[0]
960
- : candidates[1];
961
-
962
- Object.assign(destinationContent.style, {
963
- position: 'absolute',
964
- inset: '50% auto auto 50%',
965
- width: `${destinationRect.width}px`,
966
- height: `${destinationRect.height}px`,
967
- transform: 'translate(-50%, -50%) scale(.78)',
968
- color: window.getComputedStyle(destination.source).color || target.color,
969
- opacity: '0'
970
- });
971
-
972
- if (prefersReducedMotion) {
973
- panel.appendChild(destinationContent);
974
- this._applyRect(panel, targetRect);
975
- panel.style.backgroundColor = target.backgroundColor;
976
- panel.style.color = target.color;
977
- panel.style.borderRadius = target.borderRadius;
978
- panel.style.boxShadow = target.boxShadow;
979
- this._backdrop.style.opacity = '0';
980
- destinationContent.style.opacity = '1';
981
- this._finishHide(fadeTargets, headerWrapper);
982
- return;
983
- }
984
-
985
- const timeline = gsap.timeline({
986
- onComplete: () => {
987
- gsap.set(panel, { clearProps: 'transform' });
988
- this._finishHide(fadeTargets, headerWrapper);
989
- }
990
- });
991
- panel.style.left = `${current.left}px`;
992
- panel.style.top = `${current.top}px`;
993
- panel.style.width = `${current.width}px`;
994
- panel.style.height = `${current.height}px`;
995
- const panelStartStyle = window.getComputedStyle(panel);
996
- const panelStartVisual = {
997
- backgroundColor: panelStartStyle.backgroundColor,
998
- color: panelStartStyle.color,
999
- borderRadius: panelStartStyle.borderRadius,
1000
- boxShadow: panelStartStyle.boxShadow
1001
- };
1002
- // GSAP es el único propietario de la opacidad durante el recorrido.
1003
- // La transición CSS del estado `.open` produciría un segundo easing y
1004
- // haría que el scrim pareciera desaparecer al final de golpe.
385
+
386
+ /**
387
+ * Busca en el DOM el elemento que dispara el modal (el trigger) basándose en la propiedad `target`.
388
+ * Establece el listener de click que iniciará la animación de morphing.
389
+ */
390
+ private _resolveTarget(): void {
391
+ if (!this.target) return;
392
+
393
+ const el = document.querySelector<HTMLElement>(this.target);
394
+ if (!el) {
395
+ // Es normal que no se encuentre el target si está condicionado por un #if en Svelte
396
+ // Se intentará resolver nuevamente al llamar a show()
397
+ return;
398
+ }
399
+
400
+ // Si el target cambió dinámicamente, limpiamos el listener anterior para evitar memory leaks
401
+ if (this._targetEl && this._targetEl !== el) {
402
+ this._targetEl.removeEventListener('click', this._onTargetClick);
403
+ }
404
+ this._targetEl = el;
405
+ this._targetEl.addEventListener('click', this._onTargetClick);
406
+ }
407
+
408
+ /**
409
+ * Dispara la apertura del modal y coordina la animación de FLIP (First, Last, Invert, Play) / Morphing.
410
+ * 1. Oculta visualmente el target original.
411
+ * 2. Posiciona el modal colapsado exactamente sobre el target original (mismas dimensiones, color, y radios).
412
+ * 3. Expande fluidamente el modal a sus dimensiones finales mediante transformaciones CSS.
413
+ */
414
+ show(): void {
415
+ // Prevenimos ejecuciones concurrentes si ya se está animando o está visible
416
+ if (this._isAnimating || this._visible) return;
417
+
418
+ if (!this._targetEl) this._resolveTarget();
419
+ if (!this._targetEl) {
420
+ console.warn('[moni-morph-modal] cannot show: target not found');
421
+ return;
422
+ }
423
+ this._captureOriginFallbacks(this._targetEl);
424
+
425
+ this._isAnimating = true;
426
+
427
+ // Flag interno para evitar loops infinitos en el hook `updated()`
428
+ this._handlingOpenChange = true;
429
+ this.open = true;
430
+ this._handlingOpenChange = false;
431
+ this._syncDocumentScrollLock();
432
+
433
+ // Apilamos el modal globalmente para manejar correctamente el z-index en modales anidados
434
+ MoniMorphModal._openStack.push(this);
435
+ this._zIndex = 1000 + MoniMorphModal._openStack.length;
436
+
437
+ // Aplicamos variables CSS inline para gestionar las transiciones del backdrop y el modal
438
+ this.style.zIndex = String(this._zIndex);
439
+ this.style.setProperty('--_z-index', String(this._zIndex));
440
+ this.style.setProperty('--_backdrop-duration', '0.38s');
441
+ this.style.setProperty('--_backdrop-ease', 'cubic-bezier(0.15, 0.85, 0.2, 1)');
442
+ if (this._backdrop) gsap.set(this._backdrop, { clearProps: 'opacity,transition' });
443
+
444
+ // Fase "First": Ocultamos o cubrimos el trigger original según la configuración del desarrollador
445
+ this._hideTargetContent();
446
+
447
+ void this.updateComplete.then(async () => {
448
+ if (!this._targetEl || !this._panel || !this._inner) {
449
+ this._isAnimating = false;
450
+ return;
451
+ }
452
+
453
+ // Añadimos clase para prevenir interacciones durante la animación
454
+ this._panel.classList.add('is-animating');
455
+
456
+ // Esperamos un frame para asegurar que el navegador aplicó el `display: block`
457
+ // necesario antes de poder leer las dimensiones.
458
+ await new Promise((resolve) => requestAnimationFrame(resolve));
459
+
460
+ // 1. "First": Calculamos el estado visual actual del trigger
461
+ const targetState = this._getTargetState(this._targetEl);
462
+ this._debugMorph('open', this._targetEl, 'target', [this._targetEl]);
463
+ const panel = this._panel;
464
+
465
+ // 2. "Invert": Posicionamos el modal como un clon visual exacto del trigger.
466
+ // Desactivamos la sombra y forzamos el color/borde para coincidir con el botón/FAB.
467
+ this._applyRect(panel, this._toPanelCoordinateRect(targetState.rect));
468
+ panel.style.backgroundColor = targetState.backgroundColor;
469
+ panel.style.color = targetState.color;
470
+ panel.style.borderRadius = targetState.borderRadius;
471
+ panel.style.boxShadow = targetState.boxShadow;
472
+ panel.style.zIndex = String(this._zIndex);
473
+ panel.style.visibility = 'visible';
474
+ panel.style.opacity = '1';
475
+ panel.style.pointerEvents = 'auto';
476
+
477
+ if (this._backdrop) {
478
+ this._backdrop.style.zIndex = String(this._zIndex - 1);
479
+ }
480
+
481
+ // Limpiamos elementos fantasma previos si el usuario interactuó muy rápido
482
+ this._cleanupMorphElements();
483
+
484
+ // Respetamos la preferencia de sistema de animaciones reducidas por accesibilidad (a11y)
485
+ const prefersReducedMotion = window.matchMedia(
486
+ '(prefers-reduced-motion: reduce)'
487
+ ).matches;
488
+
489
+ // 3. Verificamos si debemos orquestar la animación compleja del label flotante cruzado
490
+ let shouldMorph = this._shouldMorphLabel() && !prefersReducedMotion;
491
+ const morphTargets: HTMLElement[] = [];
492
+ let source: any = null;
493
+ const headerWrapper = this.shadowRoot!.querySelector('.header-morph-wrapper') as HTMLElement;
494
+
495
+ if (shouldMorph) {
496
+ // Preparamos la estructura del header para que el texto real entre con un fade in
497
+ this._animateLabelOpen();
498
+
499
+ // Buscamos el ícono y el texto dentro del botón de origen (trigger)
500
+ source = this._resolveMorphSource();
501
+
502
+ // Si existe un nodo de texto, creamos un elemento flotante (fantasma) en el modal
503
+ if (source.text) {
504
+ const el = this._createMorphText(source.text.content, source.text.sourceEl);
505
+ this._positionElement(el, source.text.rect);
506
+ morphTargets.push(el);
507
+ }
508
+
509
+ // Hacemos lo mismo si el botón original contenía un icono
510
+ if (source.icon) {
511
+ const el = this._createMorphIcon(source.icon.element);
512
+ this._positionElement(el, source.icon.rect);
513
+ morphTargets.push(el);
514
+ }
515
+ if (morphTargets.length === 0) shouldMorph = false;
516
+ }
517
+
518
+ if (shouldMorph && headerWrapper) {
519
+ // Ocultamos temporalmente el contenido final del header mientras animamos los elementos flotantes
520
+ headerWrapper.style.opacity = '0';
521
+ }
522
+
523
+ // Capturamos el estado "First" del panel (tamaño pequeño de botón) y de los elementos flotantes
524
+ const state = Flip.getState(panel, {
525
+ props: 'backgroundColor,borderRadius,color,boxShadow'
526
+ });
527
+ const morphState = Flip.getState(morphTargets, {
528
+ props: 'color,fontSize,fontWeight,lineHeight'
529
+ });
530
+
531
+ // Fase "Last": Calculamos las dimensiones expandidas del modal basándonos en la configuración
532
+ let naturalSize: { width: number; height: number } | undefined;
533
+ if (this.autoSize || this.autoHeight) naturalSize = this._measureNaturalSize(panel);
534
+
535
+ const finalRect = this._computeFinalRect(targetState.rect, naturalSize);
536
+ panel.style.setProperty('--_panel-width', `${finalRect.width}px`);
537
+ panel.style.setProperty('--_panel-height', `${finalRect.height}px`);
538
+ this._applyRect(panel, finalRect);
539
+
540
+ // Removemos las forzadas de estilo del trigger para que herede las propias del modal
541
+ panel.style.backgroundColor = '';
542
+ panel.style.color = '';
543
+ panel.style.borderRadius = '';
544
+ panel.style.boxShadow = '';
545
+
546
+ if (shouldMorph && source) {
547
+ // Medimos dónde van a terminar el icono y el texto dentro del modal ya expandido
548
+ const textRect = this._measureHeaderTextRect();
549
+ const iconRect = this._measureHeaderIconRect();
550
+ const headerComputed = window.getComputedStyle(this._header);
551
+
552
+ if (this._morphEls.text && textRect) {
553
+ // Movemos el texto flotante a su destino y le pedimos que adopte los estilos del header
554
+ this._positionElement(this._morphEls.text, textRect);
555
+ this._morphEls.text.style.color = headerComputed.color || 'var(--on-surface)';
556
+ this._morphEls.text.style.fontSize = headerComputed.fontSize || 'inherit';
557
+ this._morphEls.text.style.fontWeight = headerComputed.fontWeight || 'inherit';
558
+ this._morphEls.text.style.lineHeight = headerComputed.lineHeight || 'inherit';
559
+ }
560
+
561
+ if (this._morphEls.icon && iconRect) {
562
+ // Hacemos lo mismo con el icono
563
+ this._positionElement(this._morphEls.icon, iconRect);
564
+ this._morphEls.icon.style.color = headerComputed.color || 'var(--on-surface)';
565
+ }
566
+ }
567
+
568
+ Flip.from(state, {
569
+ targets: panel,
570
+ duration: 0.38,
571
+ ease: 'morph-open',
572
+ zIndex: this._zIndex,
573
+ onComplete: () => {
574
+ this._isAnimating = false;
575
+ this._visible = true;
576
+ this._cleanupMorphElements();
577
+ if (headerWrapper) headerWrapper.style.opacity = '1';
578
+ }
579
+ });
580
+
581
+ if (morphTargets.length > 0) {
582
+ if (this.blurContent) {
583
+ gsap.set(morphTargets, { filter: 'blur(16px)' });
584
+ }
585
+ const flipConfig: any = {
586
+ targets: morphTargets,
587
+ duration: 0.38,
588
+ ease: 'morph-open',
589
+ zIndex: this._zIndex + 10
590
+ };
591
+ if (this.blurContent) {
592
+ flipConfig.filter = 'blur(0px)';
593
+ flipConfig.clearProps = 'filter';
594
+ }
595
+ Flip.from(morphState, flipConfig);
596
+ }
597
+
598
+ const body = this.shadowRoot!.querySelector('.body');
599
+ const footer = this.shadowRoot!.querySelector('footer');
600
+ const closeBtn = this.shadowRoot!.querySelector('.close-btn');
601
+ const fadeTargets = [body, footer, closeBtn].filter(Boolean) as HTMLElement[];
602
+
603
+ const initialBlur = this.blurContent ? 'blur(16px)' : 'blur(0px)';
604
+ gsap.set(fadeTargets, { opacity: 0, y: 8, filter: initialBlur });
605
+ gsap.fromTo(
606
+ fadeTargets,
607
+ { opacity: 0, y: 8, filter: initialBlur },
608
+ {
609
+ opacity: 1,
610
+ y: 0,
611
+ filter: 'blur(0px)',
612
+ duration: 0.32,
613
+ delay: 0.08,
614
+ stagger: 0.04,
615
+ ease: 'power2.out',
616
+ onComplete: () => {
617
+ gsap.set(fadeTargets, { clearProps: 'opacity,transform,filter' });
618
+ this._panel.classList.remove('is-animating');
619
+ }
620
+ }
621
+ );
622
+ });
623
+ }
624
+
625
+ /**
626
+ * Abre el modal realizando el morph desde un elemento proporcionado directamente.
627
+ * Útil cuando el trigger vive dentro de un Shadow DOM y no puede resolverse con `target`.
628
+ */
629
+ showFrom(target: HTMLElement): void {
630
+ if (!target) return;
631
+ this._targetEl = target;
632
+ this.show();
633
+ }
634
+
635
+ /** Redimensiona y reposiciona suavemente un modal que ya está abierto. */
636
+ resizeTo(width: string, height: string, anchor: 'center' | 'top-left' = 'center'): void {
637
+ this.expandedWidth = width;
638
+ this.expandedHeight = height;
639
+ if (!this._visible || !this._panel || !this._targetEl) return;
640
+
641
+ let finalRect = this._computeFinalRect(this._targetEl.getBoundingClientRect());
642
+ const transformedAncestor = this.parentElement?.closest<HTMLElement>('.lab-canvas, [data-morph-coordinate-space]');
643
+ const transform = transformedAncestor ? getComputedStyle(transformedAncestor).transform : 'none';
644
+ let scaleX = 1;
645
+ let scaleY = 1;
646
+ let ancestorRect: DOMRect | undefined;
647
+ if (transformedAncestor && transform !== 'none') {
648
+ const matrix = new DOMMatrix(transform);
649
+ scaleX = Math.hypot(matrix.a, matrix.b) || 1;
650
+ scaleY = Math.hypot(matrix.c, matrix.d) || 1;
651
+ ancestorRect = transformedAncestor.getBoundingClientRect();
652
+ }
653
+ if (anchor === 'top-left') {
654
+ const current = this._panel.getBoundingClientRect();
655
+ let left = current.left;
656
+ let top = current.top;
657
+ if (ancestorRect) {
658
+ left = (current.left - ancestorRect.left) / scaleX;
659
+ top = (current.top - ancestorRect.top) / scaleY;
660
+ }
661
+ finalRect = new DOMRect(left, top, finalRect.width, finalRect.height);
662
+ } else if (anchor === 'center' && ancestorRect) {
663
+ const visualLeft = window.innerWidth / 2 - (finalRect.width * scaleX) / 2;
664
+ const visualTop = window.innerHeight / 2 - (finalRect.height * scaleY) / 2;
665
+ finalRect = new DOMRect(
666
+ (visualLeft - ancestorRect.left) / scaleX,
667
+ (visualTop - ancestorRect.top) / scaleY,
668
+ finalRect.width,
669
+ finalRect.height
670
+ );
671
+ }
672
+ this._panel.classList.add('is-animating');
673
+ gsap.to(this._panel, {
674
+ top: finalRect.top,
675
+ left: finalRect.left,
676
+ width: finalRect.width,
677
+ height: finalRect.height,
678
+ '--_panel-width': `${finalRect.width}px`,
679
+ '--_panel-height': `${finalRect.height}px`,
680
+ duration: 0.32,
681
+ ease: 'morph-open',
682
+ overwrite: true,
683
+ onComplete: () => this._panel?.classList.remove('is-animating'),
684
+ onInterrupt: () => this._panel?.classList.remove('is-animating')
685
+ });
686
+ }
687
+
688
+ /**
689
+ * Solución a un caso borde de FLIP Animations:
690
+ * Si el `.panel` original carece de un color de fondo sólido, la interpolación
691
+ * de GSAP puede comportarse erráticamente. Este método "asciende" el árbol del DOM
692
+ * hasta encontrar un padre con un color de fondo sólido y lo aplica, garantizando
693
+ * que el modal no colapse de forma transparente de regreso hacia el gatillo.
694
+ */
695
+ private _ensureSolidPanelBackground() {
696
+ if (!this._panel) return;
697
+ let bg = window.getComputedStyle(this._panel).backgroundColor;
698
+ if (this._isColorTransparent(bg)) {
699
+ const solidBg = this._findSolidBackground(this._panel);
700
+ if (solidBg) {
701
+ this._panel.style.backgroundColor = solidBg;
702
+ } else {
703
+ // Aseguramos un color sólido para evitar bugs de animación FLIP hacia rgba(0,0,0,0).
704
+ // NOTA: GSAP falla si el color inline es un string con var(). Usamos un fallback seguro.
705
+ const fallback = getComputedStyle(document.body).getPropertyValue('--surface-container-high').trim();
706
+ this._panel.style.backgroundColor = fallback || '#2d2d2d';
707
+ }
708
+ }
709
+ }
710
+
711
+ hide(): void {
712
+ if (this._isAnimating || !this._visible) return;
713
+
714
+ this._isAnimating = true;
715
+ this._panel.classList.add('is-animating');
716
+
717
+ // Soluciona el caso donde el usuario no definió un fondo para .panel y lo aplicó en .body
718
+ this._ensureSolidPanelBackground();
719
+
720
+ this.style.setProperty('--_backdrop-duration', '0.3s');
721
+ this.style.setProperty('--_backdrop-ease', 'cubic-bezier(0.4, 0, 0.2, 1)');
722
+
723
+ const body = this.shadowRoot!.querySelector('.body');
724
+ const footer = this.shadowRoot!.querySelector('footer');
725
+ const closeBtn = this.shadowRoot!.querySelector('.close-btn');
726
+ const headerWrapper = this.shadowRoot!.querySelector('.header-morph-wrapper') as HTMLElement;
727
+ const fadeTargets = [body, footer, closeBtn, headerWrapper].filter(Boolean) as HTMLElement[];
728
+
729
+ // El contenido pierde definición al retirarse, evitando un corte plano
730
+ // antes de que la superficie comience su recorrido. `blurContent`
731
+ // conserva la variante más expresiva para quienes desean mayor intensidad.
732
+ const targetBlur = this.blurContent ? 'blur(12px)' : 'blur(6px)';
733
+ gsap.fromTo(
734
+ fadeTargets,
735
+ { opacity: 1, filter: 'blur(0px)' },
736
+ {
737
+ opacity: 0,
738
+ filter: targetBlur,
739
+ duration: 0.12,
740
+ ease: 'power2.in',
741
+ onComplete: () => {
742
+ const panel = this._panel;
743
+ const closingTarget = this._resolveClosingTarget();
744
+
745
+ this._cleanupMorphElements();
746
+ const prefersReducedMotion = window.matchMedia(
747
+ '(prefers-reduced-motion: reduce)'
748
+ ).matches;
749
+ // El cierre usa un único snapshot compuesto. La ruta histórica que
750
+ // creaba `morph-text` y `morph-icon` por separado podía ejecutarse a
751
+ // la vez que el snapshot y dejar etiquetas flotando sobre el modal.
752
+ let shouldMorph = false;
753
+ const morphTargets: HTMLElement[] = [];
754
+ let source: any = null;
755
+ let destinationMorph: { element: HTMLElement; rect: DOMRect; source: HTMLElement } | null = null;
756
+
757
+ if (shouldMorph) {
758
+ this._animateLabelClose();
759
+ source = this._resolveMorphSource();
760
+ const textRect = this._measureHeaderTextRect();
761
+ const iconRect = this._measureHeaderIconRect();
762
+ const headerComputed = window.getComputedStyle(this._header);
763
+
764
+ if (source.text && textRect) {
765
+ const el = this._createMorphText(source.text.content, source.text.sourceEl);
766
+ this._positionElement(el, textRect);
767
+ el.style.color = headerComputed.color || 'var(--on-surface)';
768
+ el.style.fontSize = headerComputed.fontSize || 'inherit';
769
+ el.style.fontWeight = headerComputed.fontWeight || 'inherit';
770
+ el.style.lineHeight = headerComputed.lineHeight || 'inherit';
771
+ morphTargets.push(el);
772
+ }
773
+
774
+ if (source.icon && iconRect) {
775
+ const el = this._createMorphIcon(source.icon.element);
776
+ this._positionElement(el, iconRect);
777
+ el.style.color = headerComputed.color || 'var(--on-surface)';
778
+ morphTargets.push(el);
779
+ }
780
+
781
+ if (morphTargets.length === 0) shouldMorph = false;
782
+ }
783
+
784
+ // Si el destino de cierre cambió, el contenido final debe ser una
785
+ // representación completa del target (no solamente su icono). Esto
786
+ // permite hacer morph hacia rich buttons, cards, imágenes y cualquier
787
+ // otra superficie compuesta.
788
+ if (closingTarget && !shouldMorph && !prefersReducedMotion) {
789
+ const visualTarget = this._resolveVisualTarget(closingTarget);
790
+ const destinationRect = visualTarget.getBoundingClientRect();
791
+ if (destinationRect.width > 0 && destinationRect.height > 0) {
792
+ // El contenido del trigger permanece oculto mientras el modal está
793
+ // abierto. Lo restauramos únicamente durante la captura para que el
794
+ // snapshot no herede opacity:0/color:transparent.
795
+ if (closingTarget === this._targetEl) this._restoreTargetContent();
796
+ const element = this._createTargetSnapshot(visualTarget, destinationRect);
797
+ if (closingTarget === this._targetEl) this._hideTargetContent();
798
+ element.style.opacity = '0';
799
+ destinationMorph = { element, rect: destinationRect, source: visualTarget };
800
+ }
801
+ }
802
+
803
+ if (shouldMorph && headerWrapper) {
804
+ headerWrapper.style.opacity = '0';
805
+ }
806
+
807
+ const state = Flip.getState(panel, {
808
+ props: 'backgroundColor,borderRadius,color,boxShadow'
809
+ });
810
+ const morphState = Flip.getState(morphTargets, {
811
+ props: 'color,fontSize,fontWeight,lineHeight,opacity'
812
+ });
813
+
814
+ if (!closingTarget) {
815
+ gsap.to(this._backdrop, {
816
+ opacity: 0,
817
+ duration: prefersReducedMotion ? 0.01 : 0.22,
818
+ ease: 'power2.in'
819
+ });
820
+ gsap.to(panel, {
821
+ autoAlpha: 0,
822
+ scale: 0.92,
823
+ duration: prefersReducedMotion ? 0.01 : 0.22,
824
+ ease: 'power2.in',
825
+ overwrite: true,
826
+ onComplete: () => {
827
+ gsap.set(panel, { clearProps: 'transform' });
828
+ this._finishHide(fadeTargets, headerWrapper);
829
+ }
830
+ });
831
+ return;
832
+ }
833
+
834
+ const targetState = this._getTargetState(closingTarget);
835
+ if (destinationMorph) {
836
+ this._runTargetMorphTimeline(
837
+ panel,
838
+ targetState,
839
+ destinationMorph,
840
+ fadeTargets,
841
+ headerWrapper,
842
+ prefersReducedMotion
843
+ );
844
+ return;
845
+ }
846
+
847
+ let naturalSize: { width: number; height: number } | undefined;
848
+ if (this.autoSize || this.autoHeight) naturalSize = this._measureNaturalSize(panel);
849
+
850
+ const finalRect = this._computeFinalRect(targetState.rect, naturalSize);
851
+ panel.style.setProperty('--_panel-width', `${finalRect.width}px`);
852
+ panel.style.setProperty('--_panel-height', `${finalRect.height}px`);
853
+
854
+ this._applyRect(panel, this._toPanelCoordinateRect(targetState.rect));
855
+ panel.style.backgroundColor = targetState.backgroundColor;
856
+ panel.style.color = targetState.color;
857
+ panel.style.borderRadius = targetState.borderRadius;
858
+ panel.style.boxShadow = targetState.boxShadow;
859
+
860
+ if (shouldMorph && source) {
861
+ if (this._morphEls.text && source.text) {
862
+ this._positionElement(this._morphEls.text, source.text.rect);
863
+ this._morphEls.text.style.color =
864
+ window.getComputedStyle(source.text.sourceEl).color || 'inherit';
865
+ this._morphEls.text.style.fontSize =
866
+ window.getComputedStyle(source.text.sourceEl).fontSize || 'inherit';
867
+ this._morphEls.text.style.fontWeight =
868
+ window.getComputedStyle(source.text.sourceEl).fontWeight || 'inherit';
869
+ this._morphEls.text.style.lineHeight =
870
+ window.getComputedStyle(source.text.sourceEl).lineHeight || 'inherit';
871
+ }
872
+ if (this._morphEls.icon && source.icon) {
873
+ this._positionElement(this._morphEls.icon, source.icon.rect);
874
+ this._morphEls.icon.style.color =
875
+ window.getComputedStyle(source.icon.element).color || 'inherit';
876
+ }
877
+ }
878
+ Flip.from(state, {
879
+ targets: panel,
880
+ duration: 0.3,
881
+ ease: 'morph-close',
882
+ zIndex: this._zIndex,
883
+ onComplete: () => this._finishHide(fadeTargets, headerWrapper)
884
+ });
885
+ gsap.to(this._backdrop, {
886
+ opacity: 0,
887
+ duration: 0.3,
888
+ ease: 'power2.inOut'
889
+ });
890
+
891
+ if (morphTargets.length > 0) {
892
+ if (this.blurContent) {
893
+ gsap.set(morphTargets, { filter: 'blur(0px)' });
894
+ }
895
+ const flipConfig: any = {
896
+ targets: morphTargets,
897
+ duration: 0.3,
898
+ ease: 'morph-close',
899
+ zIndex: this._zIndex + 10
900
+ };
901
+ if (this.blurContent) {
902
+ flipConfig.filter = 'blur(12px)';
903
+ }
904
+ Flip.from(morphState, flipConfig);
905
+ }
906
+ }
907
+ }
908
+ );
909
+ }
910
+
911
+ private _runTargetMorphTimeline(
912
+ panel: HTMLElement,
913
+ target: TargetState,
914
+ destination: { element: HTMLElement; rect: DOMRect; source: HTMLElement },
915
+ fadeTargets: HTMLElement[],
916
+ headerWrapper: HTMLElement | null,
917
+ prefersReducedMotion: boolean
918
+ ): void {
919
+ const current = this._toPanelCoordinateRect(panel.getBoundingClientRect());
920
+ const targetRect = this._toPanelCoordinateRect(target.rect);
921
+ const destinationRect = this._toPanelCoordinateRect(destination.rect);
922
+ const destinationContent = destination.element;
923
+ const activeTargetAnimations = destination.source.getAnimations?.({ subtree: true }) ?? [];
924
+ const remainingTargetMotion = activeTargetAnimations.reduce((remaining, animation) => {
925
+ const timing = animation.effect?.getComputedTiming();
926
+ const endTime = typeof timing?.endTime === 'number' ? timing.endTime : 0;
927
+ const currentTime = typeof animation.currentTime === 'number' ? animation.currentTime : 0;
928
+ return Math.max(remaining, Math.max(0, endTime - currentTime) / 1000);
929
+ }, 0);
930
+ const morphDuration = Math.min(1.2, Math.max(0.46, remainingTargetMotion));
931
+ const start = {
932
+ x: current.left + current.width / 2,
933
+ y: current.top + current.height / 2
934
+ };
935
+ const end = {
936
+ x: targetRect.left + targetRect.width / 2,
937
+ y: targetRect.top + targetRect.height / 2
938
+ };
939
+ const dx = end.x - start.x;
940
+ const dy = end.y - start.y;
941
+ const distance = Math.hypot(dx, dy) || 1;
942
+ // En una Bézier cuadrática el desplazamiento máximo visible equivale
943
+ // aproximadamente a la mitad del offset del control. 0.72 produce una
944
+ // curva expresiva visible (~36% de la distancia) sin formar un bucle.
945
+ const arc = Math.min(360, Math.max(48, distance * 0.72));
946
+ const perpendicular = { x: -dy / distance, y: dx / distance };
947
+ const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
948
+ const viewportCenter = this._toPanelCoordinateRect(
949
+ new DOMRect(window.innerWidth / 2, window.innerHeight / 2, 0, 0)
950
+ );
951
+ const candidates = [1, -1].map((direction) => ({
952
+ x: midpoint.x + perpendicular.x * arc * direction,
953
+ y: midpoint.y + perpendicular.y * arc * direction
954
+ }));
955
+ const centerDistance = (point: { x: number; y: number }) =>
956
+ Math.abs(
957
+ Math.abs(dy) >= Math.abs(dx)
958
+ ? point.x - viewportCenter.x
959
+ : point.y - viewportCenter.y
960
+ );
961
+ const control = centerDistance(candidates[0]) <= centerDistance(candidates[1])
962
+ ? candidates[0]
963
+ : candidates[1];
964
+
965
+ Object.assign(destinationContent.style, {
966
+ position: 'absolute',
967
+ inset: '50% auto auto 50%',
968
+ width: `${destinationRect.width}px`,
969
+ height: `${destinationRect.height}px`,
970
+ transform: 'translate(-50%, -50%) scale(.78)',
971
+ color: window.getComputedStyle(destination.source).color || target.color,
972
+ opacity: '0'
973
+ });
974
+
975
+ if (prefersReducedMotion) {
976
+ panel.appendChild(destinationContent);
977
+ this._applyRect(panel, targetRect);
978
+ panel.style.backgroundColor = target.backgroundColor;
979
+ panel.style.color = target.color;
980
+ panel.style.borderRadius = target.borderRadius;
981
+ panel.style.boxShadow = target.boxShadow;
982
+ this._backdrop.style.opacity = '0';
983
+ destinationContent.style.opacity = '1';
984
+ this._finishHide(fadeTargets, headerWrapper);
985
+ return;
986
+ }
987
+
988
+ const timeline = gsap.timeline({
989
+ onComplete: () => {
990
+ gsap.set(panel, { clearProps: 'transform' });
991
+ this._finishHide(fadeTargets, headerWrapper);
992
+ }
993
+ });
994
+ panel.style.left = `${current.left}px`;
995
+ panel.style.top = `${current.top}px`;
996
+ panel.style.width = `${current.width}px`;
997
+ panel.style.height = `${current.height}px`;
998
+ const panelStartStyle = window.getComputedStyle(panel);
999
+ const panelStartVisual = {
1000
+ backgroundColor: panelStartStyle.backgroundColor,
1001
+ color: panelStartStyle.color,
1002
+ borderRadius: panelStartStyle.borderRadius,
1003
+ boxShadow: panelStartStyle.boxShadow
1004
+ };
1005
+ // GSAP es el único propietario de la opacidad durante el recorrido.
1006
+ // La transición CSS del estado `.open` produciría un segundo easing y
1007
+ // haría que el scrim pareciera desaparecer al final de golpe.
1005
1008
  this._backdrop.style.transition = 'none';
1006
1009
  this._backdrop.style.opacity = '1';
1007
1010
  const motion = { progress: 0 };
1008
1011
  let contentAttached = false;
1012
+ const sampleInterval = 1000 / 30;
1013
+ const initialVisualWidth = destination.source.offsetWidth || targetRect.width;
1014
+ const initialVisualHeight = destination.source.offsetHeight || targetRect.height;
1015
+ const captureTargetSample = () => {
1016
+ const state = this._getTargetState(destination.source);
1017
+ const rect = this._toPanelCoordinateRect(state.rect);
1018
+ return {
1019
+ rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
1020
+ transform: this._readTransformState(destination.source),
1021
+ visualWidth: destination.source.offsetWidth || rect.width,
1022
+ visualHeight: destination.source.offsetHeight || rect.height,
1023
+ backgroundColor: state.backgroundColor,
1024
+ color: state.color,
1025
+ borderRadius: state.borderRadius,
1026
+ boxShadow: state.boxShadow
1027
+ };
1028
+ };
1029
+ let previousSample = captureTargetSample();
1030
+ let nextSample = previousSample;
1031
+ let sampledAt = performance.now();
1032
+ const lerp = (from: number, to: number, amount: number) => from + (to - from) * amount;
1009
1033
  if (this.debug) {
1010
1034
  console.log('[moni-morph-modal] arc', { start, control, end, distance, arc, morphDuration });
1011
1035
  }
1012
- timeline
1013
- .addLabel('morph', 0)
1014
- .to(motion, {
1015
- progress: 1,
1016
- duration: morphDuration,
1036
+ timeline
1037
+ .addLabel('morph', 0)
1038
+ .to(motion, {
1039
+ progress: 1,
1040
+ duration: morphDuration,
1017
1041
  ease: 'power2.inOut',
1018
1042
  onUpdate: () => {
1019
1043
  const progress = motion.progress;
1020
- const liveState = this._getTargetState(destination.source);
1021
- const liveRect = this._toPanelCoordinateRect(liveState.rect);
1022
- const liveTransform = this._readTransformState(destination.source);
1023
- const liveEnd = {
1024
- x: liveRect.left + liveRect.width / 2,
1025
- y: liveRect.top + liveRect.height / 2
1044
+ const now = performance.now();
1045
+ const forceFinalSample = progress >= 0.999;
1046
+ if (forceFinalSample || now - sampledAt >= sampleInterval) {
1047
+ previousSample = nextSample;
1048
+ nextSample = captureTargetSample();
1049
+ sampledAt = now;
1050
+ }
1051
+ const sampleProgress = forceFinalSample
1052
+ ? 1
1053
+ : Math.min(1, (now - sampledAt) / sampleInterval);
1054
+ const liveRect = {
1055
+ left: lerp(previousSample.rect.left, nextSample.rect.left, sampleProgress),
1056
+ top: lerp(previousSample.rect.top, nextSample.rect.top, sampleProgress),
1057
+ width: lerp(previousSample.rect.width, nextSample.rect.width, sampleProgress),
1058
+ height: lerp(previousSample.rect.height, nextSample.rect.height, sampleProgress)
1026
1059
  };
1027
- const liveDx = liveEnd.x - start.x;
1028
- const liveDy = liveEnd.y - start.y;
1029
- const liveDistance = Math.hypot(liveDx, liveDy) || 1;
1030
- const liveArc = Math.min(360, Math.max(48, liveDistance * 0.72));
1031
- const livePerpendicular = { x: -liveDy / liveDistance, y: liveDx / liveDistance };
1032
- const liveMidpoint = {
1033
- x: (start.x + liveEnd.x) / 2,
1034
- y: (start.y + liveEnd.y) / 2
1060
+ const liveTransform = {
1061
+ rotation: lerp(previousSample.transform.rotation, nextSample.transform.rotation, sampleProgress),
1062
+ skewX: lerp(previousSample.transform.skewX, nextSample.transform.skewX, sampleProgress),
1063
+ scaleX: lerp(previousSample.transform.scaleX, nextSample.transform.scaleX, sampleProgress),
1064
+ scaleY: lerp(previousSample.transform.scaleY, nextSample.transform.scaleY, sampleProgress)
1035
1065
  };
1036
- const liveCandidates = [1, -1].map((direction) => ({
1037
- x: liveMidpoint.x + livePerpendicular.x * liveArc * direction,
1038
- y: liveMidpoint.y + livePerpendicular.y * liveArc * direction
1039
- }));
1040
- const liveControl = centerDistance(liveCandidates[0]) <= centerDistance(liveCandidates[1])
1041
- ? liveCandidates[0]
1042
- : liveCandidates[1];
1043
- // El backdrop comparte exactamente el playhead del arco. Se mantiene
1044
- // algo más presente al comienzo y luego se retira progresivamente.
1045
- this._backdrop.style.opacity = String(Math.max(0, 1 - progress));
1046
- const inverse = 1 - progress;
1047
- const centerX =
1048
- inverse * inverse * start.x +
1049
- 2 * inverse * progress * liveControl.x +
1050
- progress * progress * liveEnd.x;
1051
- const centerY =
1052
- inverse * inverse * start.y +
1053
- 2 * inverse * progress * liveControl.y +
1054
- progress * progress * liveEnd.y;
1055
- // La contracción modal mantiene su easing, pero cualquier mutación
1056
- // adicional del target se suma completa para seguirla en tiempo real.
1057
- const liveVisualWidth = destination.source.offsetWidth || liveRect.width;
1058
- const liveVisualHeight = destination.source.offsetHeight || liveRect.height;
1059
- const initialVisualWidth = destination.source.offsetWidth || targetRect.width;
1060
- const initialVisualHeight = destination.source.offsetHeight || targetRect.height;
1061
- const width =
1062
- current.width + (initialVisualWidth - current.width) * progress +
1063
- (liveVisualWidth - initialVisualWidth);
1064
- const height =
1065
- current.height + (initialVisualHeight - current.height) * progress +
1066
- (liveVisualHeight - initialVisualHeight);
1067
- panel.style.width = `${width}px`;
1068
- panel.style.height = `${height}px`;
1069
- gsap.set(panel, {
1070
- x: centerX - width / 2 - current.left,
1071
- y: centerY - height / 2 - current.top,
1072
- rotation: liveTransform.rotation * progress,
1073
- skewX: liveTransform.skewX * progress,
1074
- scaleX: 1 + (liveTransform.scaleX - 1) * progress,
1075
- scaleY: 1 + (liveTransform.scaleY - 1) * progress,
1066
+ const liveEnd = {
1067
+ x: liveRect.left + liveRect.width / 2,
1068
+ y: liveRect.top + liveRect.height / 2
1069
+ };
1070
+ const liveDx = liveEnd.x - start.x;
1071
+ const liveDy = liveEnd.y - start.y;
1072
+ const liveDistance = Math.hypot(liveDx, liveDy) || 1;
1073
+ const liveArc = Math.min(360, Math.max(48, liveDistance * 0.72));
1074
+ const livePerpendicular = { x: -liveDy / liveDistance, y: liveDx / liveDistance };
1075
+ const liveMidpoint = {
1076
+ x: (start.x + liveEnd.x) / 2,
1077
+ y: (start.y + liveEnd.y) / 2
1078
+ };
1079
+ const liveCandidates = [1, -1].map((direction) => ({
1080
+ x: liveMidpoint.x + livePerpendicular.x * liveArc * direction,
1081
+ y: liveMidpoint.y + livePerpendicular.y * liveArc * direction
1082
+ }));
1083
+ const liveControl = centerDistance(liveCandidates[0]) <= centerDistance(liveCandidates[1])
1084
+ ? liveCandidates[0]
1085
+ : liveCandidates[1];
1086
+ // El backdrop comparte exactamente el playhead del arco. Se mantiene
1087
+ // algo más presente al comienzo y luego se retira progresivamente.
1088
+ this._backdrop.style.opacity = String(Math.max(0, 1 - progress));
1089
+ const inverse = 1 - progress;
1090
+ const centerX =
1091
+ inverse * inverse * start.x +
1092
+ 2 * inverse * progress * liveControl.x +
1093
+ progress * progress * liveEnd.x;
1094
+ const centerY =
1095
+ inverse * inverse * start.y +
1096
+ 2 * inverse * progress * liveControl.y +
1097
+ progress * progress * liveEnd.y;
1098
+ // La contracción modal mantiene su easing, pero cualquier mutación
1099
+ // adicional del target se suma completa para seguirla en tiempo real.
1100
+ const liveVisualWidth = lerp(previousSample.visualWidth, nextSample.visualWidth, sampleProgress);
1101
+ const liveVisualHeight = lerp(previousSample.visualHeight, nextSample.visualHeight, sampleProgress);
1102
+ const width =
1103
+ current.width + (initialVisualWidth - current.width) * progress +
1104
+ (liveVisualWidth - initialVisualWidth);
1105
+ const height =
1106
+ current.height + (initialVisualHeight - current.height) * progress +
1107
+ (liveVisualHeight - initialVisualHeight);
1108
+ panel.style.width = `${width}px`;
1109
+ panel.style.height = `${height}px`;
1110
+ gsap.set(panel, {
1111
+ x: centerX - width / 2 - current.left,
1112
+ y: centerY - height / 2 - current.top,
1113
+ rotation: liveTransform.rotation * progress,
1114
+ skewX: liveTransform.skewX * progress,
1115
+ scaleX: 1 + (liveTransform.scaleX - 1) * progress,
1116
+ scaleY: 1 + (liveTransform.scaleY - 1) * progress,
1076
1117
  backgroundColor: gsap.utils.interpolate(
1077
1118
  panelStartVisual.backgroundColor,
1078
- liveState.backgroundColor,
1119
+ gsap.utils.interpolate(previousSample.backgroundColor, nextSample.backgroundColor, sampleProgress),
1120
+ progress
1121
+ ),
1122
+ color: gsap.utils.interpolate(
1123
+ panelStartVisual.color,
1124
+ gsap.utils.interpolate(previousSample.color, nextSample.color, sampleProgress),
1079
1125
  progress
1080
1126
  ),
1081
- color: gsap.utils.interpolate(panelStartVisual.color, liveState.color, progress),
1082
1127
  borderRadius: gsap.utils.interpolate(
1083
1128
  panelStartVisual.borderRadius,
1084
- liveState.borderRadius,
1129
+ gsap.utils.interpolate(previousSample.borderRadius, nextSample.borderRadius, sampleProgress),
1085
1130
  progress
1086
1131
  ),
1087
1132
  boxShadow: gsap.utils.interpolate(
1088
1133
  panelStartVisual.boxShadow,
1089
- liveState.boxShadow,
1134
+ gsap.utils.interpolate(previousSample.boxShadow, nextSample.boxShadow, sampleProgress),
1090
1135
  progress
1091
- )
1092
- });
1093
- // El contenido del target no debe flotar sobre el modal grande. Solo
1094
- // comienza a materializarse cuando posición y relación de aspecto ya
1095
- // se aproximan al destino, pero continúa viajando con la superficie.
1096
- const contentProgress = Math.max(0, Math.min(1, (progress - 0.6) / 0.4));
1097
- if (!contentAttached && contentProgress > 0) {
1098
- panel.appendChild(destinationContent);
1099
- contentAttached = true;
1100
- }
1101
- // Conservamos el layout nativo del target y escalamos toda su
1102
- // composición. Redimensionar el wrapper convertiría porcentajes
1103
- // computados en píxeles fijos y dejaría la imagen arriba a la izquierda.
1104
- destinationContent.style.width = `${liveVisualWidth}px`;
1105
- destinationContent.style.height = `${liveVisualHeight}px`;
1106
- const revealScale = 0.82 + contentProgress * 0.18;
1107
- gsap.set(destinationContent, {
1108
- opacity: contentProgress,
1109
- scaleX: (width / Math.max(1, liveVisualWidth)) * revealScale,
1110
- scaleY: (height / Math.max(1, liveVisualHeight)) * revealScale
1111
- });
1112
- }
1113
- }, 'morph')
1114
- .addLabel('target-fade')
1115
- .to(panel, {
1116
- autoAlpha: 0,
1117
- duration: 0.08,
1118
- ease: 'power1.out',
1119
- onStart: () => {
1120
- // Handoff atómico: el contenido real ocupa el lugar del snapshot
1121
- // antes de retirar la última superficie. Evita un frame vacío o una
1122
- // duplicación perceptible al finalizar el morph.
1123
- destinationContent.style.visibility = 'hidden';
1124
- this._restoreTargetContent();
1125
- panel.style.backgroundColor = 'transparent';
1126
- panel.style.boxShadow = 'none';
1127
- panel.style.borderColor = 'transparent';
1128
- }
1129
- }, `morph+=${morphDuration}`);
1130
- }
1131
-
1132
- private _finishHide(fadeTargets: HTMLElement[], headerWrapper: HTMLElement | null): void {
1133
- const panel = this._panel;
1134
- panel.style.visibility = 'hidden';
1135
- panel.style.opacity = '0';
1136
- panel.style.pointerEvents = 'none';
1137
- this._visible = false;
1138
- this._handlingOpenChange = true;
1139
- this.open = false;
1140
- this._handlingOpenChange = false;
1141
- this._isAnimating = false;
1142
- panel.classList.remove('is-animating');
1143
- panel.style.removeProperty('--_panel-width');
1144
- panel.style.removeProperty('--_panel-height');
1145
- this._cleanupMorphElements();
1146
- this._restoreTargetContent();
1147
- gsap.set(fadeTargets, { clearProps: 'opacity,transform,filter' });
1148
- if (headerWrapper) headerWrapper.style.opacity = '1';
1149
- this.style.removeProperty('z-index');
1150
- this.style.removeProperty('--_z-index');
1151
- this.style.removeProperty('--_backdrop-duration');
1152
- this.style.removeProperty('--_backdrop-ease');
1153
- // Conservamos opacity:0 hasta la próxima apertura. Limpiarla aquí puede
1154
- // producir un flash de scrim antes de que Lit retire la clase `.open`.
1136
+ )
1137
+ });
1138
+ // El contenido del target no debe flotar sobre el modal grande. Solo
1139
+ // comienza a materializarse cuando posición y relación de aspecto ya
1140
+ // se aproximan al destino, pero continúa viajando con la superficie.
1141
+ const contentProgress = Math.max(0, Math.min(1, (progress - 0.6) / 0.4));
1142
+ if (!contentAttached && contentProgress > 0) {
1143
+ panel.appendChild(destinationContent);
1144
+ contentAttached = true;
1145
+ }
1146
+ // Conservamos el layout nativo del target y escalamos toda su
1147
+ // composición. Redimensionar el wrapper convertiría porcentajes
1148
+ // computados en píxeles fijos y dejaría la imagen arriba a la izquierda.
1149
+ destinationContent.style.width = `${liveVisualWidth}px`;
1150
+ destinationContent.style.height = `${liveVisualHeight}px`;
1151
+ const revealScale = 0.82 + contentProgress * 0.18;
1152
+ gsap.set(destinationContent, {
1153
+ opacity: contentProgress,
1154
+ scaleX: (width / Math.max(1, liveVisualWidth)) * revealScale,
1155
+ scaleY: (height / Math.max(1, liveVisualHeight)) * revealScale
1156
+ });
1157
+ }
1158
+ }, 'morph')
1159
+ .addLabel('target-fade')
1160
+ .to(panel, {
1161
+ autoAlpha: 0,
1162
+ duration: 0.08,
1163
+ ease: 'power1.out',
1164
+ onStart: () => {
1165
+ // Handoff atómico: el contenido real ocupa el lugar del snapshot
1166
+ // antes de retirar la última superficie. Evita un frame vacío o una
1167
+ // duplicación perceptible al finalizar el morph.
1168
+ destinationContent.style.visibility = 'hidden';
1169
+ this._restoreTargetContent();
1170
+ panel.style.backgroundColor = 'transparent';
1171
+ panel.style.boxShadow = 'none';
1172
+ panel.style.borderColor = 'transparent';
1173
+ }
1174
+ }, `morph+=${morphDuration}`);
1175
+ }
1176
+
1177
+ private _finishHide(fadeTargets: HTMLElement[], headerWrapper: HTMLElement | null): void {
1178
+ const panel = this._panel;
1179
+ panel.style.visibility = 'hidden';
1180
+ panel.style.opacity = '0';
1181
+ panel.style.pointerEvents = 'none';
1182
+ this._visible = false;
1183
+ this._handlingOpenChange = true;
1184
+ this.open = false;
1185
+ this._handlingOpenChange = false;
1186
+ this._isAnimating = false;
1187
+ panel.classList.remove('is-animating');
1188
+ panel.style.removeProperty('--_panel-width');
1189
+ panel.style.removeProperty('--_panel-height');
1190
+ this._cleanupMorphElements();
1191
+ this._restoreTargetContent();
1192
+ gsap.set(fadeTargets, { clearProps: 'opacity,transform,filter' });
1193
+ if (headerWrapper) headerWrapper.style.opacity = '1';
1194
+ this.style.removeProperty('z-index');
1195
+ this.style.removeProperty('--_z-index');
1196
+ this.style.removeProperty('--_backdrop-duration');
1197
+ this.style.removeProperty('--_backdrop-ease');
1198
+ // Conservamos opacity:0 hasta la próxima apertura. Limpiarla aquí puede
1199
+ // producir un flash de scrim antes de que Lit retire la clase `.open`.
1155
1200
  const idx = MoniMorphModal._openStack.indexOf(this);
1156
1201
  if (idx > -1) MoniMorphModal._openStack.splice(idx, 1);
1202
+ this._releaseDocumentScrollLock();
1157
1203
  }
1158
-
1159
- private _captureOriginFallbacks(origin: HTMLElement): void {
1160
- this._originFallbacks = [];
1161
- let current: HTMLElement | null = this._getComposedParent(origin);
1162
- while (current && current !== document.body && current !== document.documentElement) {
1163
- this._originFallbacks.push(current);
1164
- current = this._getComposedParent(current);
1165
- }
1166
- }
1167
-
1168
- private _getComposedParent(el: HTMLElement): HTMLElement | null {
1169
- if (el.parentElement) return el.parentElement;
1170
- const root = el.getRootNode();
1171
- return root instanceof ShadowRoot ? (root.host as HTMLElement) : null;
1172
- }
1173
-
1204
+
1205
+ private _captureOriginFallbacks(origin: HTMLElement): void {
1206
+ this._originFallbacks = [];
1207
+ let current: HTMLElement | null = this._getComposedParent(origin);
1208
+ while (current && current !== document.body && current !== document.documentElement) {
1209
+ this._originFallbacks.push(current);
1210
+ current = this._getComposedParent(current);
1211
+ }
1212
+ }
1213
+
1214
+ private _getComposedParent(el: HTMLElement): HTMLElement | null {
1215
+ if (el.parentElement) return el.parentElement;
1216
+ const root = el.getRootNode();
1217
+ return root instanceof ShadowRoot ? (root.host as HTMLElement) : null;
1218
+ }
1219
+
1174
1220
  private _resolveClosingTarget(): HTMLElement | null {
1175
1221
  if (this.target) this._resolveTarget();
1176
1222
  const candidates = [this._targetEl, ...this._originFallbacks].filter(
1177
1223
  (candidate, index, all): candidate is HTMLElement => !!candidate && all.indexOf(candidate) === index
1178
1224
  );
1225
+
1226
+ // Si el trigger original sigue en el DOM pero salio del viewport, no se
1227
+ // debe sustituir por uno de sus ancestros visibles. Ese fallback haria que
1228
+ // el modal colapsara hacia un contenedor que nunca fue su origen visual.
1229
+ // En este caso el cierre seguro es la animacion sin origen (fade + scale).
1230
+ if (this._targetEl?.isConnected && !this._intersectsViewport(this._targetEl)) {
1231
+ this._debugMorph('close', null, 'fade', candidates);
1232
+ return null;
1233
+ }
1234
+
1179
1235
  const resolved = candidates.find((candidate) => this._isUsableClosingTarget(candidate)) ?? null;
1180
1236
  const strategy = !resolved ? 'fade' : resolved === this._targetEl ? 'target' : 'ancestor';
1181
1237
  this._debugMorph('close', resolved, strategy, candidates);
1182
1238
  return resolved;
1183
1239
  }
1184
1240
 
1185
- private _debugMorph(
1186
- phase: 'open' | 'close',
1187
- resolved: HTMLElement | null,
1188
- strategy: 'target' | 'ancestor' | 'fade',
1189
- candidates: HTMLElement[]
1190
- ): void {
1191
- if (!this.debug) return;
1192
- const rows = candidates.map((element, index) => {
1193
- const rect = element.getBoundingClientRect();
1194
- return {
1195
- index,
1196
- element: `${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ''}`,
1197
- connected: element.isConnected,
1198
- usable: this._isUsableClosingTarget(element),
1199
- x: Math.round(rect.x),
1200
- y: Math.round(rect.y),
1201
- width: Math.round(rect.width),
1202
- height: Math.round(rect.height)
1203
- };
1204
- });
1205
- console.groupCollapsed(`[moni-morph-modal] ${phase} → ${strategy}`);
1206
- console.log('Origin:', this._targetEl);
1207
- console.log('Resolved morph target:', resolved);
1208
- console.table(rows);
1209
- console.groupEnd();
1241
+ private _intersectsViewport(el: HTMLElement): boolean {
1242
+ const rect = el.getBoundingClientRect();
1243
+ return rect.width >= 2
1244
+ && rect.height >= 2
1245
+ && rect.right > 0
1246
+ && rect.bottom > 0
1247
+ && rect.left < window.innerWidth
1248
+ && rect.top < window.innerHeight;
1210
1249
  }
1211
-
1250
+
1251
+ private _debugMorph(
1252
+ phase: 'open' | 'close',
1253
+ resolved: HTMLElement | null,
1254
+ strategy: 'target' | 'ancestor' | 'fade',
1255
+ candidates: HTMLElement[]
1256
+ ): void {
1257
+ if (!this.debug) return;
1258
+ const rows = candidates.map((element, index) => {
1259
+ const rect = element.getBoundingClientRect();
1260
+ return {
1261
+ index,
1262
+ element: `${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ''}`,
1263
+ connected: element.isConnected,
1264
+ usable: this._isUsableClosingTarget(element),
1265
+ x: Math.round(rect.x),
1266
+ y: Math.round(rect.y),
1267
+ width: Math.round(rect.width),
1268
+ height: Math.round(rect.height)
1269
+ };
1270
+ });
1271
+ console.groupCollapsed(`[moni-morph-modal] ${phase} → ${strategy}`);
1272
+ console.log('Origin:', this._targetEl);
1273
+ console.log('Resolved morph target:', resolved);
1274
+ console.table(rows);
1275
+ console.groupEnd();
1276
+ }
1277
+
1212
1278
  private _isUsableClosingTarget(el: HTMLElement | null): el is HTMLElement {
1213
1279
  if (!el?.isConnected) return false;
1214
- const rect = el.getBoundingClientRect();
1215
- if (rect.width < 2 || rect.height < 2) return false;
1216
- if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= window.innerWidth || rect.top >= window.innerHeight) return false;
1217
- let current: HTMLElement | null = el;
1218
- while (current && current !== document.body) {
1219
- const style = getComputedStyle(current);
1220
- if (style.display === 'none' || style.visibility === 'hidden' || style.contentVisibility === 'hidden') return false;
1221
- if (Number.parseFloat(style.opacity || '1') === 0 && !this._targetStylesCache.has(current)) return false;
1222
- current = this._getComposedParent(current);
1223
- }
1224
- return true;
1225
- }
1226
-
1227
- toggle(): void {
1228
- if (this._visible || this.open) this.hide();
1229
- else this.show();
1230
- }
1231
-
1232
- private _handleDocKeydown(e: KeyboardEvent): void {
1233
- if (e.key !== 'Escape' || !this.closeOnEsc) return;
1234
- const top = MoniMorphModal._openStack[MoniMorphModal._openStack.length - 1];
1235
- if (top !== this) return;
1236
- e.stopPropagation();
1237
- this.hide();
1238
- }
1239
-
1240
- /**
1241
- * Fase "First" de la animación (Cierre del Trigger):
1242
- * Determina cómo desaparecer u ocultar el botón original mientras el modal ocupa su lugar.
1243
- * Mantiene el cache de opacidades y colores originales para poder restaurarlos
1244
- * idénticamente al cerrar el modal (evitando saltos visuales).
1245
- */
1246
- private _hideTargetContent() {
1247
- if (!this._targetEl) return;
1248
-
1249
- const saveAndHide = (el: HTMLElement, prop: 'opacity' | 'color') => {
1250
- if (!this._targetStylesCache.has(el)) {
1251
- this._targetStylesCache.set(el, { opacity: el.style.opacity, color: el.style.color });
1252
- }
1253
- if (prop === 'opacity') el.style.opacity = '0';
1254
- if (prop === 'color') el.style.color = 'transparent';
1255
- };
1256
-
1257
- if (this.hideTarget) {
1258
- saveAndHide(this._targetEl, 'opacity');
1259
- return;
1260
- }
1261
-
1262
- if (this._targetEl.tagName === 'MONI-BUTTON') {
1263
- const parts = this._getMoniButtonParts();
1264
- if (parts.label) saveAndHide(parts.label as HTMLElement, 'opacity');
1265
- if (parts.icon) saveAndHide(parts.icon as HTMLElement, 'opacity');
1266
- if (parts.iconTrailing) saveAndHide(parts.iconTrailing as HTMLElement, 'opacity');
1267
- } else if (this._targetEl.tagName === 'MONI-FAB') {
1268
- const icon = this._targetEl.shadowRoot?.querySelector('.icon') as HTMLElement | null;
1269
- const label = this._targetEl.shadowRoot?.querySelector('.label') as HTMLElement | null;
1270
- if (icon) saveAndHide(icon, 'opacity');
1271
- if (label) saveAndHide(label, 'opacity');
1272
- } else {
1273
- saveAndHide(this._targetEl, 'color');
1274
- const icon = this._targetEl.querySelector('moni-icon, svg, [class*="icon"]');
1275
- if (icon) saveAndHide(icon as HTMLElement, 'opacity');
1276
- }
1277
- }
1278
-
1279
- /**
1280
- * Restaura puramente los estilos originales del botón Trigger tras terminar
1281
- * la animación de cierre, leyendo las opacidades/colores almacenados en caché.
1282
- */
1283
- private _restoreTargetContent() {
1284
- if (!this._targetEl) return;
1285
-
1286
- const restore = (el: HTMLElement) => {
1287
- if (this._targetStylesCache.has(el)) {
1288
- const cache = this._targetStylesCache.get(el)!;
1289
- el.style.opacity = cache.opacity;
1290
- el.style.color = cache.color;
1291
- this._targetStylesCache.delete(el);
1292
- }
1293
- };
1294
-
1295
- if (this.hideTarget) {
1296
- restore(this._targetEl);
1297
- return;
1298
- }
1299
-
1300
- if (this._targetEl.tagName === 'MONI-BUTTON') {
1301
- const parts = this._getMoniButtonParts();
1302
- if (parts.label) restore(parts.label as HTMLElement);
1303
- if (parts.icon) restore(parts.icon as HTMLElement);
1304
- if (parts.iconTrailing) restore(parts.iconTrailing as HTMLElement);
1305
- } else if (this._targetEl.tagName === 'MONI-FAB') {
1306
- const icon = this._targetEl.shadowRoot?.querySelector('.icon') as HTMLElement | null;
1307
- const label = this._targetEl.shadowRoot?.querySelector('.label') as HTMLElement | null;
1308
- if (icon) restore(icon);
1309
- if (label) restore(label);
1310
- } else {
1311
- restore(this._targetEl);
1312
- const icon = this._targetEl.querySelector('moni-icon, svg, [class*="icon"]');
1313
- if (icon) restore(icon as HTMLElement);
1314
- }
1315
- }
1316
-
1317
- private _onHeaderSlotChange(e: Event): void {
1318
- const slot = e.target as HTMLSlotElement;
1319
- this._hasHeader = slot.assignedNodes({ flatten: true }).length > 0;
1320
- }
1321
-
1322
- private _onFooterSlotChange(e: Event): void {
1323
- const slot = e.target as HTMLSlotElement;
1324
- this._hasFooter = slot.assignedNodes({ flatten: true }).length > 0;
1325
- }
1326
-
1327
- private _onTriggerLabelSlotChange(e: Event): void {
1328
- const slot = e.target as HTMLSlotElement;
1329
- this._hasTriggerLabel = slot.assignedNodes({ flatten: true }).length > 0;
1330
- }
1331
-
1332
- private _getTriggerLabelNodes(): Node[] {
1333
- const slot = this.shadowRoot?.querySelector(
1334
- 'slot[name="trigger-label"]'
1335
- ) as HTMLSlotElement | undefined;
1336
- return slot?.assignedNodes({ flatten: true }) ?? [];
1337
- }
1338
-
1339
- private _shouldMorphLabel(): boolean {
1340
- return (
1341
- this.morphLabel && !!this._targetEl && (this._hasHeader || this._hasTriggerLabel)
1342
- );
1343
- }
1344
-
1345
- // =========================================================================
1346
- // LABEL FLIP HELPERS
1347
- // =========================================================================
1348
-
1349
- private _cleanupMorphElements(): void {
1350
- if (this._targetSnapshot) {
1351
- this._targetSnapshot.remove();
1352
- this._targetSnapshot = null;
1353
- }
1354
- if (this._morphEls.text) {
1355
- this._morphEls.text.remove();
1356
- this._morphEls.text = null;
1357
- }
1358
- if (this._morphEls.icon) {
1359
- this._morphEls.icon.remove();
1360
- this._morphEls.icon = null;
1361
- }
1362
- }
1363
-
1364
- private _animateLabelOpen(): void {
1365
- // No-op: el morphing de la etiqueta es manejado por el timeline unificado FLIP en show()
1366
- }
1367
-
1368
- private _animateLabelClose(): void {
1369
- // No-op: el morphing de la etiqueta es manejado por el timeline unificado FLIP en hide()
1370
- }
1371
-
1372
- private _createMorphText(sourceText: string, sourceEl: Element): HTMLElement {
1373
- const el = document.createElement('div');
1374
- el.className = 'morph-text';
1375
- el.setAttribute('data-flip-id', `morph-text-${this.target}`);
1376
- el.setAttribute('aria-hidden', 'true');
1377
- el.textContent = sourceText;
1378
-
1379
- const computed = window.getComputedStyle(sourceEl);
1380
- el.style.position = 'fixed';
1381
- el.style.pointerEvents = 'none';
1382
- el.style.zIndex = String(this._zIndex + 10);
1383
- el.style.display = 'inline-flex';
1384
- el.style.alignItems = computed.alignItems !== 'normal' ? computed.alignItems : 'center';
1385
- el.style.justifyContent = computed.justifyContent !== 'normal' ? computed.justifyContent : 'flex-start';
1386
- el.style.whiteSpace = 'nowrap';
1387
- el.style.color = computed.color || 'inherit';
1388
- el.style.fontFamily = computed.fontFamily || 'inherit';
1389
- el.style.fontSize = computed.fontSize || 'inherit';
1390
- el.style.fontWeight = computed.fontWeight || 'inherit';
1391
- el.style.lineHeight = computed.lineHeight || 'inherit';
1392
- el.style.letterSpacing = computed.letterSpacing || 'inherit';
1393
-
1394
- this.shadowRoot!.appendChild(el);
1395
- this._morphEls.text = el;
1396
- return el;
1397
- }
1398
-
1399
- private _createMorphIcon(sourceIcon: Element): HTMLElement {
1400
- const el = document.createElement('div');
1401
- el.className = 'morph-icon';
1402
- el.setAttribute('data-flip-id', `morph-icon-${this.target}`);
1403
- el.setAttribute('aria-hidden', 'true');
1404
-
1405
- // Para <moni-icon> renderizamos el glifo directamente para evitar
1406
- // esperar a que hidrate su shadow DOM.
1407
- if (sourceIcon.tagName === 'MONI-ICON') {
1408
- const span = document.createElement('span');
1409
- span.textContent = sourceIcon.getAttribute('name') || '';
1410
- const computed = window.getComputedStyle(sourceIcon);
1411
- span.style.display = 'inline-flex';
1412
- span.style.alignItems = 'center';
1413
- span.style.justifyContent = 'center';
1414
- span.style.fontFamily = computed.fontFamily || 'var(--font-icon)';
1415
- span.style.fontSize = computed.fontSize || '1.125rem';
1416
- span.style.inlineSize = computed.inlineSize || '1.125rem';
1417
- span.style.blockSize = computed.blockSize || '1.125rem';
1418
- span.style.lineHeight = '1';
1419
- span.style.color = 'inherit';
1420
- span.style.fontWeight = 'normal';
1421
- span.style.fontStyle = 'normal';
1422
- span.style.letterSpacing = 'normal';
1423
- span.style.textTransform = 'none';
1424
- span.style.whiteSpace = 'nowrap';
1425
- span.style.wordWrap = 'normal';
1426
- span.style.direction = 'ltr';
1427
- span.style.fontFeatureSettings = "'liga'";
1428
- span.style.fontVariationSettings =
1429
- "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24";
1430
- span.style.setProperty('-webkit-font-smoothing', 'antialiased');
1431
- el.appendChild(span);
1432
- } else {
1433
- el.appendChild(sourceIcon.cloneNode(true) as HTMLElement);
1434
- }
1435
-
1436
- const computed = window.getComputedStyle(sourceIcon);
1437
- el.style.position = 'fixed';
1438
- el.style.pointerEvents = 'none';
1439
- el.style.zIndex = String(this._zIndex + 10);
1440
- el.style.display = 'inline-flex';
1441
- el.style.alignItems = 'center';
1442
- el.style.justifyContent = 'center';
1443
- el.style.color = computed.color || 'inherit';
1444
- el.style.inlineSize = computed.inlineSize || '1.125rem';
1445
- el.style.blockSize = computed.blockSize || '1.125rem';
1446
-
1447
- this.shadowRoot!.appendChild(el);
1448
- this._morphEls.icon = el;
1449
- return el;
1450
- }
1451
-
1452
- private _positionElement(el: HTMLElement, rect: DOMRect): void {
1453
- const localRect = this._toPanelCoordinateRect(rect);
1454
- el.style.top = `${localRect.top}px`;
1455
- el.style.left = `${localRect.left}px`;
1456
- el.style.width = `${localRect.width}px`;
1457
- el.style.height = `${localRect.height}px`;
1458
- }
1459
-
1460
- private _toPanelCoordinateRect(rect: DOMRect): DOMRect {
1461
- const ancestor = this.parentElement?.closest<HTMLElement>(
1462
- '.lab-canvas, [data-morph-coordinate-space]'
1463
- );
1464
- if (!ancestor) return rect;
1465
- const transform = getComputedStyle(ancestor).transform;
1466
- if (!transform || transform === 'none') return rect;
1467
- let scaleX = 1;
1468
- let scaleY = 1;
1469
- if (typeof DOMMatrix !== 'undefined') {
1470
- const matrix = new DOMMatrix(transform);
1471
- scaleX = Math.hypot(matrix.a, matrix.b) || 1;
1472
- scaleY = Math.hypot(matrix.c, matrix.d) || 1;
1473
- } else {
1474
- const matrixMatch = transform.match(/^matrix\(([^)]+)\)$/);
1475
- const scaleMatch = transform.match(/^scale\(([^)]+)\)$/);
1476
- if (matrixMatch) {
1477
- const values = matrixMatch[1].split(',').map(Number);
1478
- scaleX = Math.hypot(values[0], values[1]) || 1;
1479
- scaleY = Math.hypot(values[2], values[3]) || 1;
1480
- } else if (scaleMatch) {
1481
- const values = scaleMatch[1].split(/[,\s]+/).map(Number);
1482
- scaleX = values[0] || 1;
1483
- scaleY = values[1] || scaleX;
1484
- }
1485
- }
1486
- const ancestorRect = ancestor.getBoundingClientRect();
1487
- return new DOMRect(
1488
- (rect.left - ancestorRect.left) / scaleX,
1489
- (rect.top - ancestorRect.top) / scaleY,
1490
- rect.width / scaleX,
1491
- rect.height / scaleY
1492
- );
1493
- }
1494
-
1495
- private _getMoniButtonParts(): {
1496
- label: Element | null;
1497
- icon: Element | null;
1498
- iconTrailing: Element | null;
1499
- } {
1500
- const sr = this._targetEl?.shadowRoot;
1501
- return {
1502
- label: sr?.querySelector('[part="label"]') ?? null,
1503
- icon: sr?.querySelector('[part="icon"]') ?? null,
1504
- iconTrailing: sr?.querySelector('[part="icon-trailing"]') ?? null
1505
- };
1506
- }
1507
-
1508
- private _resolveMorphSource(): {
1509
- text: { content: string; rect: DOMRect; sourceEl: Element } | null;
1510
- icon: { element: Element; rect: DOMRect } | null;
1511
- } {
1512
- const result: {
1513
- text: { content: string; rect: DOMRect; sourceEl: Element } | null;
1514
- icon: { element: Element; rect: DOMRect } | null;
1515
- } = { text: null, icon: null };
1516
-
1517
- if (!this._targetEl) return result;
1518
-
1519
- // Slot trigger-label tiene prioridad.
1520
- if (this._hasTriggerLabel) {
1521
- const nodes = this._getTriggerLabelNodes();
1522
- if (nodes.length > 0) {
1523
- const wrapper = document.createElement('div');
1524
- for (const node of nodes) {
1525
- wrapper.appendChild(node.cloneNode(true));
1526
- }
1527
- // Posicionamos el wrapper sobre el target para la medición inicial.
1528
- wrapper.style.position = 'fixed';
1529
- wrapper.style.visibility = 'hidden';
1530
- wrapper.style.pointerEvents = 'none';
1531
- this.shadowRoot!.appendChild(wrapper);
1532
- const rect = this._targetEl.getBoundingClientRect();
1533
- wrapper.remove();
1534
-
1535
- result.text = {
1536
- content: wrapper.textContent?.trim() || '',
1537
- rect,
1538
- sourceEl: this._targetEl
1539
- };
1540
- }
1541
- return result;
1542
- }
1543
-
1544
- // <moni-button>: extraemos label e icono desde su shadow DOM.
1545
- if (this._targetEl.tagName === 'MONI-BUTTON') {
1546
- const parts = this._getMoniButtonParts();
1547
- if (parts.label) {
1548
- result.text = {
1549
- content: this._targetEl.textContent?.trim() || '',
1550
- rect: parts.label.getBoundingClientRect(),
1551
- sourceEl: parts.label
1552
- };
1553
- }
1554
- if (parts.icon) {
1555
- const innerIcon = parts.icon.querySelector('moni-icon, svg') || parts.icon;
1556
- result.icon = {
1557
- element: innerIcon,
1558
- rect: innerIcon.getBoundingClientRect()
1559
- };
1560
- }
1561
- return result;
1562
- }
1563
-
1564
- // Target arbitrario.
1565
- let sourceEl: Element = this._targetEl;
1566
- if (this.morphLabelSelector) {
1567
- const selected = this._targetEl.querySelector(this.morphLabelSelector);
1568
- if (selected) sourceEl = selected;
1569
- }
1570
-
1571
- const text = sourceEl.textContent?.trim() || '';
1572
- if (text) {
1573
- result.text = {
1574
- content: text,
1575
- rect: sourceEl.getBoundingClientRect(),
1576
- sourceEl
1577
- };
1578
- }
1579
-
1580
- // Buscamos un icono dentro del target.
1581
- const iconEl = sourceEl.querySelector('moni-icon, svg, [class*="icon"]');
1582
- if (iconEl) {
1583
- result.icon = {
1584
- element: iconEl,
1585
- rect: iconEl.getBoundingClientRect()
1586
- };
1587
- }
1588
-
1589
- return result;
1590
- }
1591
-
1592
- private _measureHeaderTextRect(): DOMRect | null {
1593
- if (!this._header) return null;
1594
-
1595
- const slot = this.shadowRoot?.querySelector(
1596
- 'slot[name="header"]'
1597
- ) as HTMLSlotElement | undefined;
1598
- const nodes = slot?.assignedNodes({ flatten: true }) ?? [];
1599
-
1600
- // Buscamos el primer nodo con texto visible.
1601
- for (const node of nodes) {
1602
- if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
1603
- const range = document.createRange();
1604
- range.selectNode(node);
1605
- return range.getBoundingClientRect();
1606
- }
1607
- if (node.nodeType === Node.ELEMENT_NODE) {
1608
- const el = node as Element;
1609
- if (el.tagName === 'MONI-ICON' || el.tagName === 'SVG') {
1610
- continue;
1611
- }
1612
- const childNodes = Array.from(el.childNodes);
1613
- for (const child of childNodes) {
1614
- if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) {
1615
- const range = document.createRange();
1616
- range.selectNode(child);
1617
- return range.getBoundingClientRect();
1618
- }
1619
- if (child.nodeType === Node.ELEMENT_NODE) {
1620
- const cel = child as Element;
1621
- if (cel.tagName !== 'MONI-ICON' && cel.tagName !== 'SVG' && cel.textContent?.trim()) {
1622
- return cel.getBoundingClientRect();
1623
- }
1624
- }
1625
- }
1626
- if (el.textContent?.trim()) {
1627
- return el.getBoundingClientRect();
1628
- }
1629
- }
1630
- }
1631
-
1632
- return this._header.getBoundingClientRect();
1633
- }
1634
-
1635
- private _measureHeaderIconRect(): DOMRect | null {
1636
- const textRect = this._measureHeaderTextRect();
1637
- if (!textRect) return null;
1638
-
1639
- const slot = this.shadowRoot?.querySelector(
1640
- 'slot[name="header"]'
1641
- ) as HTMLSlotElement | undefined;
1642
- const nodes = slot?.assignedNodes({ flatten: true }) ?? [];
1643
-
1644
- // Si el header ya tiene un icono, lo usamos como destino.
1645
- for (const node of nodes) {
1646
- if (node.nodeType === Node.ELEMENT_NODE) {
1647
- const el = node as Element;
1648
- if (el.tagName === 'MONI-ICON' || el.tagName === 'SVG') {
1649
- return el.getBoundingClientRect();
1650
- }
1651
- const nestedIcon = el.querySelector('moni-icon, svg');
1652
- if (nestedIcon) {
1653
- return nestedIcon.getBoundingClientRect();
1654
- }
1655
- }
1656
- }
1657
-
1658
- // Si no hay icono en el header, calculamos una posición a la
1659
- // izquierda del texto, alineada verticalmente.
1660
- const iconSize = 24;
1661
- const gap = 12;
1662
- return new DOMRect(
1663
- textRect.left - iconSize - gap,
1664
- textRect.top + textRect.height / 2 - iconSize / 2,
1665
- iconSize,
1666
- iconSize
1667
- );
1668
- }
1669
-
1670
- private _findSolidBackground(el: Element): string | null {
1671
- const elements = [el, ...Array.from(el.querySelectorAll('*'))];
1672
- for (const e of elements) {
1673
- const bg = window.getComputedStyle(e).backgroundColor;
1674
- if (!this._isColorTransparent(bg)) {
1675
- return bg;
1676
- }
1677
- }
1678
- // Si es un Web Component, revisamos su shadowRoot de forma superficial
1679
- if (el.shadowRoot) {
1680
- const shadowElements = Array.from(el.shadowRoot.querySelectorAll('*'));
1681
- for (const e of shadowElements) {
1682
- const bg = window.getComputedStyle(e).backgroundColor;
1683
- if (!this._isColorTransparent(bg)) {
1684
- return bg;
1685
- }
1686
- }
1687
- }
1688
- return null;
1689
- }
1690
-
1691
- private _getTargetState(el: HTMLElement): TargetState {
1692
- // Los Web Components interactivos exponen un host transparente. El morph
1693
- // debe usar la superficie pintada real dentro de su Shadow DOM.
1694
- const visualEl = this._resolveVisualTarget(el);
1695
- const rect = visualEl.getBoundingClientRect();
1696
-
1697
- let bg = window.getComputedStyle(visualEl).backgroundColor;
1698
-
1699
- // Si es transparente, intentamos buscar en pseudo-elementos (usado en botones Material 3)
1700
- if (this._isColorTransparent(bg)) {
1701
- const beforeBg = window.getComputedStyle(visualEl, '::before').backgroundColor;
1702
- if (!this._isColorTransparent(beforeBg)) {
1703
- bg = beforeBg;
1704
- } else {
1705
- const afterBg = window.getComputedStyle(visualEl, '::after').backgroundColor;
1706
- if (!this._isColorTransparent(afterBg)) {
1707
- bg = afterBg;
1708
- } else {
1709
- // Buscamos el primer elemento con fondo sólido en el árbol del objetivo
1710
- const solidBg = this._findSolidBackground(visualEl);
1711
- if (solidBg) bg = solidBg;
1712
- }
1713
- }
1714
- }
1715
-
1716
- // Si sigue siendo transparente, evitamos que el modal se vuelva transparente
1717
- // leyendo su propio color computado actual para que mantenga su opacidad.
1718
- if (this._isColorTransparent(bg) && this._panel) {
1719
- bg = window.getComputedStyle(this._panel).backgroundColor;
1720
- }
1721
-
1722
- const computed = window.getComputedStyle(visualEl);
1723
-
1724
- return {
1725
- rect,
1726
- backgroundColor: bg,
1727
- color: computed.color || 'var(--on-surface)',
1728
- borderRadius: computed.borderRadius || '1.25rem',
1729
- boxShadow: computed.boxShadow || 'none'
1730
- };
1731
- }
1732
-
1733
- private _resolveVisualTarget(el: HTMLElement): HTMLElement {
1734
- if (el.tagName === 'MONI-BUTTON') {
1735
- return (el.shadowRoot?.querySelector('.button') as HTMLElement | null) ?? el;
1736
- }
1737
- if (el.tagName === 'MONI-FAB') {
1738
- return (el.shadowRoot?.querySelector('button') as HTMLElement | null) ?? el;
1739
- }
1740
- if (el.tagName === 'MONI-FAB-MENU') {
1741
- const trigger = el.shadowRoot?.querySelector('.trigger') as HTMLElement | null;
1742
- return (trigger?.shadowRoot?.querySelector('button') as HTMLElement | null) ?? trigger ?? el;
1743
- }
1744
- return el;
1745
- }
1746
-
1747
- /**
1748
- * Crea una copia visual inerte del árbol compuesto del target. Los `<slot>`
1749
- * se sustituyen por sus nodos asignados, por lo que la captura incluye tanto
1750
- * contenido light DOM como superficies internas de Web Components.
1751
- */
1752
- private _createTargetSnapshot(source: HTMLElement, rect: DOMRect): HTMLElement {
1753
- this._targetSnapshot?.remove();
1754
- const snapshot = document.createElement('div');
1755
- snapshot.className = 'morph-target-snapshot';
1756
- snapshot.setAttribute('aria-hidden', 'true');
1757
- const clone = this._cloneComposedNode(source);
1758
- if (clone) {
1759
- snapshot.appendChild(clone);
1760
- // La geometría, el color y la elevación pertenecen al panel animado.
1761
- // Repetirlos en el snapshot crea una segunda píldora encima del morph.
1762
- // En medios visuales, en cambio, la propia superficie sí es contenido.
1763
- if (
1764
- clone instanceof HTMLElement &&
1765
- !['IMG', 'VIDEO', 'CANVAS', 'SVG', 'PICTURE'].includes(source.tagName)
1766
- ) {
1767
- Object.assign(clone.style, {
1768
- background: 'transparent',
1769
- backgroundColor: 'transparent',
1770
- boxShadow: 'none',
1771
- borderColor: 'transparent',
1772
- outline: 'none',
1773
- position: 'relative',
1774
- inset: 'auto',
1775
- transform: 'none',
1776
- opacity: '1',
1777
- visibility: 'visible',
1778
- width: '100%',
1779
- height: '100%'
1780
- });
1781
- if (source.tagName === 'BUTTON') {
1782
- clone.style.whiteSpace = 'nowrap';
1783
- clone.querySelectorAll<HTMLElement>('span, [part~="label"]').forEach((child) => {
1784
- child.style.whiteSpace = 'nowrap';
1785
- });
1786
- }
1787
- }
1788
- }
1789
- Object.assign(snapshot.style, {
1790
- width: `${rect.width}px`,
1791
- height: `${rect.height}px`,
1792
- overflow: 'hidden',
1793
- pointerEvents: 'none'
1794
- });
1795
- this._targetSnapshot = snapshot;
1796
- return snapshot;
1797
- }
1798
-
1799
- private _cloneComposedNode(source: Node): Node | null {
1800
- if (source.nodeType === Node.TEXT_NODE) return source.cloneNode(false);
1801
- if (!(source instanceof Element)) return null;
1802
-
1803
- if (source instanceof HTMLSlotElement) {
1804
- const fragment = document.createDocumentFragment();
1805
- const nodes = source.assignedNodes({ flatten: true });
1806
- for (const node of nodes.length ? nodes : Array.from(source.childNodes)) {
1807
- const child = this._cloneComposedNode(node);
1808
- if (child) fragment.appendChild(child);
1809
- }
1810
- return fragment;
1811
- }
1812
-
1813
- // No clonamos directamente un Custom Element con Shadow DOM: su
1814
- // constructor crearía una segunda sombra y duplicaría el render. Una
1815
- // superficie neutra recibe en cambio el árbol visual ya compuesto.
1816
- const clone = (source.shadowRoot
1817
- ? document.createElement('div')
1818
- : source.cloneNode(false)) as HTMLElement;
1819
- if (source instanceof HTMLImageElement && clone instanceof HTMLImageElement) {
1820
- // El snapshot vive menos de medio segundo: no puede depender de que el
1821
- // lazy-loader observe nuevamente una imagen que ya estaba renderizada.
1822
- clone.loading = 'eager';
1823
- clone.decoding = 'sync';
1824
- if (source.currentSrc) clone.src = source.currentSrc;
1825
- }
1826
- clone.removeAttribute('id');
1827
- clone.removeAttribute('name');
1828
- clone.setAttribute('aria-hidden', 'true');
1829
- const computed = window.getComputedStyle(source);
1830
- for (let index = 0; index < computed.length; index += 1) {
1831
- const property = computed.item(index);
1832
- clone.style.setProperty(property, computed.getPropertyValue(property), computed.getPropertyPriority(property));
1833
- }
1834
-
1835
- const children = source.shadowRoot
1836
- ? Array.from(source.shadowRoot.childNodes).filter(
1837
- (node) => !(node instanceof HTMLStyleElement)
1838
- )
1839
- : Array.from(source.childNodes);
1840
- for (const node of children) {
1841
- const child = this._cloneComposedNode(node);
1842
- if (child) clone.appendChild(child);
1843
- }
1844
- return clone;
1845
- }
1846
-
1847
- private _isColorTransparent(color: string | null | undefined): boolean {
1848
- if (!color) return true;
1849
- const c = color.replace(/\s/g, '').toLowerCase();
1850
- return (
1851
- c === 'rgba(0,0,0,0)' ||
1852
- c === 'transparent' ||
1853
- c === '' ||
1854
- c.includes(',0)') ||
1855
- c.includes('/0)')
1856
- );
1857
- }
1858
-
1859
- private _readTransformState(el: HTMLElement): {
1860
- rotation: number;
1861
- skewX: number;
1862
- scaleX: number;
1863
- scaleY: number;
1864
- } {
1865
- const transform = window.getComputedStyle(el).transform;
1866
- if (!transform || transform === 'none') {
1867
- return { rotation: 0, skewX: 0, scaleX: 1, scaleY: 1 };
1868
- }
1869
- try {
1870
- const values = transform.match(/matrix(?:3d)?\(([^)]+)\)/)?.[1]
1871
- .split(',')
1872
- .map((value) => Number.parseFloat(value.trim()));
1873
- let a: number;
1874
- let b: number;
1875
- let c: number;
1876
- let d: number;
1877
- if (values?.length === 6) {
1878
- [a, b, c, d] = values;
1879
- } else if (values?.length === 16) {
1880
- a = values[0];
1881
- b = values[1];
1882
- c = values[4];
1883
- d = values[5];
1884
- } else {
1885
- const matrix = new DOMMatrix(transform);
1886
- ({ a, b, c, d } = matrix);
1887
- }
1888
- const scaleX = Math.hypot(a, b) || 1;
1889
- const determinant = a * d - b * c;
1890
- const scaleY = determinant / scaleX || 1;
1891
- return {
1892
- rotation: Math.atan2(b, a) * (180 / Math.PI),
1893
- skewX:
1894
- Math.atan2(a * c + b * d, scaleX * scaleX) *
1895
- (180 / Math.PI),
1896
- scaleX,
1897
- scaleY
1898
- };
1899
- } catch {
1900
- return { rotation: 0, skewX: 0, scaleX: 1, scaleY: 1 };
1901
- }
1902
- }
1903
-
1904
- private _applyRect(el: HTMLElement, rect: DOMRect): void {
1905
- el.style.top = `${rect.top}px`;
1906
- el.style.left = `${rect.left}px`;
1907
- el.style.width = `${rect.width}px`;
1908
- el.style.height = `${rect.height}px`;
1909
- }
1910
-
1280
+ if (!this._intersectsViewport(el)) return false;
1281
+ let current: HTMLElement | null = el;
1282
+ while (current && current !== document.body) {
1283
+ const style = getComputedStyle(current);
1284
+ if (style.display === 'none' || style.visibility === 'hidden' || style.contentVisibility === 'hidden') return false;
1285
+ if (Number.parseFloat(style.opacity || '1') === 0 && !this._targetStylesCache.has(current)) return false;
1286
+ current = this._getComposedParent(current);
1287
+ }
1288
+ return true;
1289
+ }
1290
+
1291
+ toggle(): void {
1292
+ if (this._visible || this.open) this.hide();
1293
+ else this.show();
1294
+ }
1295
+
1296
+ private _handleDocKeydown(e: KeyboardEvent): void {
1297
+ if (e.key !== 'Escape' || !this.closeOnEsc) return;
1298
+ const top = MoniMorphModal._openStack[MoniMorphModal._openStack.length - 1];
1299
+ if (top !== this) return;
1300
+ e.stopPropagation();
1301
+ this.hide();
1302
+ }
1303
+
1304
+ /**
1305
+ * Fase "First" de la animación (Cierre del Trigger):
1306
+ * Determina cómo desaparecer u ocultar el botón original mientras el modal ocupa su lugar.
1307
+ * Mantiene el cache de opacidades y colores originales para poder restaurarlos
1308
+ * idénticamente al cerrar el modal (evitando saltos visuales).
1309
+ */
1310
+ private _hideTargetContent() {
1311
+ if (!this._targetEl) return;
1312
+
1313
+ const saveAndHide = (el: HTMLElement, prop: 'opacity' | 'color') => {
1314
+ if (!this._targetStylesCache.has(el)) {
1315
+ this._targetStylesCache.set(el, { opacity: el.style.opacity, color: el.style.color });
1316
+ }
1317
+ if (prop === 'opacity') el.style.opacity = '0';
1318
+ if (prop === 'color') el.style.color = 'transparent';
1319
+ };
1320
+
1321
+ if (this.hideTarget) {
1322
+ saveAndHide(this._targetEl, 'opacity');
1323
+ return;
1324
+ }
1325
+
1326
+ if (this._targetEl.tagName === 'MONI-BUTTON') {
1327
+ const parts = this._getMoniButtonParts();
1328
+ if (parts.label) saveAndHide(parts.label as HTMLElement, 'opacity');
1329
+ if (parts.icon) saveAndHide(parts.icon as HTMLElement, 'opacity');
1330
+ if (parts.iconTrailing) saveAndHide(parts.iconTrailing as HTMLElement, 'opacity');
1331
+ } else if (this._targetEl.tagName === 'MONI-FAB') {
1332
+ const icon = this._targetEl.shadowRoot?.querySelector('.icon') as HTMLElement | null;
1333
+ const label = this._targetEl.shadowRoot?.querySelector('.label') as HTMLElement | null;
1334
+ if (icon) saveAndHide(icon, 'opacity');
1335
+ if (label) saveAndHide(label, 'opacity');
1336
+ } else {
1337
+ saveAndHide(this._targetEl, 'color');
1338
+ const icon = this._targetEl.querySelector('moni-icon, svg, [class*="icon"]');
1339
+ if (icon) saveAndHide(icon as HTMLElement, 'opacity');
1340
+ }
1341
+ }
1342
+
1343
+ /**
1344
+ * Restaura puramente los estilos originales del botón Trigger tras terminar
1345
+ * la animación de cierre, leyendo las opacidades/colores almacenados en caché.
1346
+ */
1347
+ private _restoreTargetContent() {
1348
+ if (!this._targetEl) return;
1349
+
1350
+ const restore = (el: HTMLElement) => {
1351
+ if (this._targetStylesCache.has(el)) {
1352
+ const cache = this._targetStylesCache.get(el)!;
1353
+ el.style.opacity = cache.opacity;
1354
+ el.style.color = cache.color;
1355
+ this._targetStylesCache.delete(el);
1356
+ }
1357
+ };
1358
+
1359
+ if (this.hideTarget) {
1360
+ restore(this._targetEl);
1361
+ return;
1362
+ }
1363
+
1364
+ if (this._targetEl.tagName === 'MONI-BUTTON') {
1365
+ const parts = this._getMoniButtonParts();
1366
+ if (parts.label) restore(parts.label as HTMLElement);
1367
+ if (parts.icon) restore(parts.icon as HTMLElement);
1368
+ if (parts.iconTrailing) restore(parts.iconTrailing as HTMLElement);
1369
+ } else if (this._targetEl.tagName === 'MONI-FAB') {
1370
+ const icon = this._targetEl.shadowRoot?.querySelector('.icon') as HTMLElement | null;
1371
+ const label = this._targetEl.shadowRoot?.querySelector('.label') as HTMLElement | null;
1372
+ if (icon) restore(icon);
1373
+ if (label) restore(label);
1374
+ } else {
1375
+ restore(this._targetEl);
1376
+ const icon = this._targetEl.querySelector('moni-icon, svg, [class*="icon"]');
1377
+ if (icon) restore(icon as HTMLElement);
1378
+ }
1379
+ }
1380
+
1381
+ private _onHeaderSlotChange(e: Event): void {
1382
+ const slot = e.target as HTMLSlotElement;
1383
+ this._hasHeader = slot.assignedNodes({ flatten: true }).length > 0;
1384
+ }
1385
+
1386
+ private _onFooterSlotChange(e: Event): void {
1387
+ const slot = e.target as HTMLSlotElement;
1388
+ this._hasFooter = slot.assignedNodes({ flatten: true }).length > 0;
1389
+ }
1390
+
1391
+ private _onTriggerLabelSlotChange(e: Event): void {
1392
+ const slot = e.target as HTMLSlotElement;
1393
+ this._hasTriggerLabel = slot.assignedNodes({ flatten: true }).length > 0;
1394
+ }
1395
+
1396
+ private _getTriggerLabelNodes(): Node[] {
1397
+ const slot = this.shadowRoot?.querySelector(
1398
+ 'slot[name="trigger-label"]'
1399
+ ) as HTMLSlotElement | undefined;
1400
+ return slot?.assignedNodes({ flatten: true }) ?? [];
1401
+ }
1402
+
1403
+ private _shouldMorphLabel(): boolean {
1404
+ return (
1405
+ this.morphLabel && !!this._targetEl && (this._hasHeader || this._hasTriggerLabel)
1406
+ );
1407
+ }
1408
+
1409
+ // =========================================================================
1410
+ // LABEL FLIP HELPERS
1411
+ // =========================================================================
1412
+
1413
+ private _cleanupMorphElements(): void {
1414
+ if (this._targetSnapshot) {
1415
+ this._targetSnapshot.remove();
1416
+ this._targetSnapshot = null;
1417
+ }
1418
+ if (this._morphEls.text) {
1419
+ this._morphEls.text.remove();
1420
+ this._morphEls.text = null;
1421
+ }
1422
+ if (this._morphEls.icon) {
1423
+ this._morphEls.icon.remove();
1424
+ this._morphEls.icon = null;
1425
+ }
1426
+ }
1427
+
1428
+ private _animateLabelOpen(): void {
1429
+ // No-op: el morphing de la etiqueta es manejado por el timeline unificado FLIP en show()
1430
+ }
1431
+
1432
+ private _animateLabelClose(): void {
1433
+ // No-op: el morphing de la etiqueta es manejado por el timeline unificado FLIP en hide()
1434
+ }
1435
+
1436
+ private _createMorphText(sourceText: string, sourceEl: Element): HTMLElement {
1437
+ const el = document.createElement('div');
1438
+ el.className = 'morph-text';
1439
+ el.setAttribute('data-flip-id', `morph-text-${this.target}`);
1440
+ el.setAttribute('aria-hidden', 'true');
1441
+ el.textContent = sourceText;
1442
+
1443
+ const computed = window.getComputedStyle(sourceEl);
1444
+ el.style.position = 'fixed';
1445
+ el.style.pointerEvents = 'none';
1446
+ el.style.zIndex = String(this._zIndex + 10);
1447
+ el.style.display = 'inline-flex';
1448
+ el.style.alignItems = computed.alignItems !== 'normal' ? computed.alignItems : 'center';
1449
+ el.style.justifyContent = computed.justifyContent !== 'normal' ? computed.justifyContent : 'flex-start';
1450
+ el.style.whiteSpace = 'nowrap';
1451
+ el.style.color = computed.color || 'inherit';
1452
+ el.style.fontFamily = computed.fontFamily || 'inherit';
1453
+ el.style.fontSize = computed.fontSize || 'inherit';
1454
+ el.style.fontWeight = computed.fontWeight || 'inherit';
1455
+ el.style.lineHeight = computed.lineHeight || 'inherit';
1456
+ el.style.letterSpacing = computed.letterSpacing || 'inherit';
1457
+
1458
+ this.shadowRoot!.appendChild(el);
1459
+ this._morphEls.text = el;
1460
+ return el;
1461
+ }
1462
+
1463
+ private _createMorphIcon(sourceIcon: Element): HTMLElement {
1464
+ const el = document.createElement('div');
1465
+ el.className = 'morph-icon';
1466
+ el.setAttribute('data-flip-id', `morph-icon-${this.target}`);
1467
+ el.setAttribute('aria-hidden', 'true');
1468
+
1469
+ // Para <moni-icon> renderizamos el glifo directamente para evitar
1470
+ // esperar a que hidrate su shadow DOM.
1471
+ if (sourceIcon.tagName === 'MONI-ICON') {
1472
+ const span = document.createElement('span');
1473
+ span.textContent = sourceIcon.getAttribute('name') || '';
1474
+ const computed = window.getComputedStyle(sourceIcon);
1475
+ span.style.display = 'inline-flex';
1476
+ span.style.alignItems = 'center';
1477
+ span.style.justifyContent = 'center';
1478
+ span.style.fontFamily = computed.fontFamily || 'var(--font-icon)';
1479
+ span.style.fontSize = computed.fontSize || '1.125rem';
1480
+ span.style.inlineSize = computed.inlineSize || '1.125rem';
1481
+ span.style.blockSize = computed.blockSize || '1.125rem';
1482
+ span.style.lineHeight = '1';
1483
+ span.style.color = 'inherit';
1484
+ span.style.fontWeight = 'normal';
1485
+ span.style.fontStyle = 'normal';
1486
+ span.style.letterSpacing = 'normal';
1487
+ span.style.textTransform = 'none';
1488
+ span.style.whiteSpace = 'nowrap';
1489
+ span.style.wordWrap = 'normal';
1490
+ span.style.direction = 'ltr';
1491
+ span.style.fontFeatureSettings = "'liga'";
1492
+ span.style.fontVariationSettings =
1493
+ "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24";
1494
+ span.style.setProperty('-webkit-font-smoothing', 'antialiased');
1495
+ el.appendChild(span);
1496
+ } else {
1497
+ el.appendChild(sourceIcon.cloneNode(true) as HTMLElement);
1498
+ }
1499
+
1500
+ const computed = window.getComputedStyle(sourceIcon);
1501
+ el.style.position = 'fixed';
1502
+ el.style.pointerEvents = 'none';
1503
+ el.style.zIndex = String(this._zIndex + 10);
1504
+ el.style.display = 'inline-flex';
1505
+ el.style.alignItems = 'center';
1506
+ el.style.justifyContent = 'center';
1507
+ el.style.color = computed.color || 'inherit';
1508
+ el.style.inlineSize = computed.inlineSize || '1.125rem';
1509
+ el.style.blockSize = computed.blockSize || '1.125rem';
1510
+
1511
+ this.shadowRoot!.appendChild(el);
1512
+ this._morphEls.icon = el;
1513
+ return el;
1514
+ }
1515
+
1516
+ private _positionElement(el: HTMLElement, rect: DOMRect): void {
1517
+ const localRect = this._toPanelCoordinateRect(rect);
1518
+ el.style.top = `${localRect.top}px`;
1519
+ el.style.left = `${localRect.left}px`;
1520
+ el.style.width = `${localRect.width}px`;
1521
+ el.style.height = `${localRect.height}px`;
1522
+ }
1523
+
1524
+ private _toPanelCoordinateRect(rect: DOMRect): DOMRect {
1525
+ const ancestor = this.parentElement?.closest<HTMLElement>(
1526
+ '.lab-canvas, [data-morph-coordinate-space]'
1527
+ );
1528
+ if (!ancestor) return rect;
1529
+ const transform = getComputedStyle(ancestor).transform;
1530
+ if (!transform || transform === 'none') return rect;
1531
+ let scaleX = 1;
1532
+ let scaleY = 1;
1533
+ if (typeof DOMMatrix !== 'undefined') {
1534
+ const matrix = new DOMMatrix(transform);
1535
+ scaleX = Math.hypot(matrix.a, matrix.b) || 1;
1536
+ scaleY = Math.hypot(matrix.c, matrix.d) || 1;
1537
+ } else {
1538
+ const matrixMatch = transform.match(/^matrix\(([^)]+)\)$/);
1539
+ const scaleMatch = transform.match(/^scale\(([^)]+)\)$/);
1540
+ if (matrixMatch) {
1541
+ const values = matrixMatch[1].split(',').map(Number);
1542
+ scaleX = Math.hypot(values[0], values[1]) || 1;
1543
+ scaleY = Math.hypot(values[2], values[3]) || 1;
1544
+ } else if (scaleMatch) {
1545
+ const values = scaleMatch[1].split(/[,\s]+/).map(Number);
1546
+ scaleX = values[0] || 1;
1547
+ scaleY = values[1] || scaleX;
1548
+ }
1549
+ }
1550
+ const ancestorRect = ancestor.getBoundingClientRect();
1551
+ return new DOMRect(
1552
+ (rect.left - ancestorRect.left) / scaleX,
1553
+ (rect.top - ancestorRect.top) / scaleY,
1554
+ rect.width / scaleX,
1555
+ rect.height / scaleY
1556
+ );
1557
+ }
1558
+
1559
+ private _getMoniButtonParts(): {
1560
+ label: Element | null;
1561
+ icon: Element | null;
1562
+ iconTrailing: Element | null;
1563
+ } {
1564
+ const sr = this._targetEl?.shadowRoot;
1565
+ return {
1566
+ label: sr?.querySelector('[part="label"]') ?? null,
1567
+ icon: sr?.querySelector('[part="icon"]') ?? null,
1568
+ iconTrailing: sr?.querySelector('[part="icon-trailing"]') ?? null
1569
+ };
1570
+ }
1571
+
1572
+ private _resolveMorphSource(): {
1573
+ text: { content: string; rect: DOMRect; sourceEl: Element } | null;
1574
+ icon: { element: Element; rect: DOMRect } | null;
1575
+ } {
1576
+ const result: {
1577
+ text: { content: string; rect: DOMRect; sourceEl: Element } | null;
1578
+ icon: { element: Element; rect: DOMRect } | null;
1579
+ } = { text: null, icon: null };
1580
+
1581
+ if (!this._targetEl) return result;
1582
+
1583
+ // Slot trigger-label tiene prioridad.
1584
+ if (this._hasTriggerLabel) {
1585
+ const nodes = this._getTriggerLabelNodes();
1586
+ if (nodes.length > 0) {
1587
+ const wrapper = document.createElement('div');
1588
+ for (const node of nodes) {
1589
+ wrapper.appendChild(node.cloneNode(true));
1590
+ }
1591
+ // Posicionamos el wrapper sobre el target para la medición inicial.
1592
+ wrapper.style.position = 'fixed';
1593
+ wrapper.style.visibility = 'hidden';
1594
+ wrapper.style.pointerEvents = 'none';
1595
+ this.shadowRoot!.appendChild(wrapper);
1596
+ const rect = this._targetEl.getBoundingClientRect();
1597
+ wrapper.remove();
1598
+
1599
+ result.text = {
1600
+ content: wrapper.textContent?.trim() || '',
1601
+ rect,
1602
+ sourceEl: this._targetEl
1603
+ };
1604
+ }
1605
+ return result;
1606
+ }
1607
+
1608
+ // <moni-button>: extraemos label e icono desde su shadow DOM.
1609
+ if (this._targetEl.tagName === 'MONI-BUTTON') {
1610
+ const parts = this._getMoniButtonParts();
1611
+ if (parts.label) {
1612
+ result.text = {
1613
+ content: this._targetEl.textContent?.trim() || '',
1614
+ rect: parts.label.getBoundingClientRect(),
1615
+ sourceEl: parts.label
1616
+ };
1617
+ }
1618
+ if (parts.icon) {
1619
+ const innerIcon = parts.icon.querySelector('moni-icon, svg') || parts.icon;
1620
+ result.icon = {
1621
+ element: innerIcon,
1622
+ rect: innerIcon.getBoundingClientRect()
1623
+ };
1624
+ }
1625
+ return result;
1626
+ }
1627
+
1628
+ // Target arbitrario.
1629
+ let sourceEl: Element = this._targetEl;
1630
+ if (this.morphLabelSelector) {
1631
+ const selected = this._targetEl.querySelector(this.morphLabelSelector);
1632
+ if (selected) sourceEl = selected;
1633
+ }
1634
+
1635
+ const text = sourceEl.textContent?.trim() || '';
1636
+ if (text) {
1637
+ result.text = {
1638
+ content: text,
1639
+ rect: sourceEl.getBoundingClientRect(),
1640
+ sourceEl
1641
+ };
1642
+ }
1643
+
1644
+ // Buscamos un icono dentro del target.
1645
+ const iconEl = sourceEl.querySelector('moni-icon, svg, [class*="icon"]');
1646
+ if (iconEl) {
1647
+ result.icon = {
1648
+ element: iconEl,
1649
+ rect: iconEl.getBoundingClientRect()
1650
+ };
1651
+ }
1652
+
1653
+ return result;
1654
+ }
1655
+
1656
+ private _measureHeaderTextRect(): DOMRect | null {
1657
+ if (!this._header) return null;
1658
+
1659
+ const slot = this.shadowRoot?.querySelector(
1660
+ 'slot[name="header"]'
1661
+ ) as HTMLSlotElement | undefined;
1662
+ const nodes = slot?.assignedNodes({ flatten: true }) ?? [];
1663
+
1664
+ // Buscamos el primer nodo con texto visible.
1665
+ for (const node of nodes) {
1666
+ if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
1667
+ const range = document.createRange();
1668
+ range.selectNode(node);
1669
+ return range.getBoundingClientRect();
1670
+ }
1671
+ if (node.nodeType === Node.ELEMENT_NODE) {
1672
+ const el = node as Element;
1673
+ if (el.tagName === 'MONI-ICON' || el.tagName === 'SVG') {
1674
+ continue;
1675
+ }
1676
+ const childNodes = Array.from(el.childNodes);
1677
+ for (const child of childNodes) {
1678
+ if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) {
1679
+ const range = document.createRange();
1680
+ range.selectNode(child);
1681
+ return range.getBoundingClientRect();
1682
+ }
1683
+ if (child.nodeType === Node.ELEMENT_NODE) {
1684
+ const cel = child as Element;
1685
+ if (cel.tagName !== 'MONI-ICON' && cel.tagName !== 'SVG' && cel.textContent?.trim()) {
1686
+ return cel.getBoundingClientRect();
1687
+ }
1688
+ }
1689
+ }
1690
+ if (el.textContent?.trim()) {
1691
+ return el.getBoundingClientRect();
1692
+ }
1693
+ }
1694
+ }
1695
+
1696
+ return this._header.getBoundingClientRect();
1697
+ }
1698
+
1699
+ private _measureHeaderIconRect(): DOMRect | null {
1700
+ const textRect = this._measureHeaderTextRect();
1701
+ if (!textRect) return null;
1702
+
1703
+ const slot = this.shadowRoot?.querySelector(
1704
+ 'slot[name="header"]'
1705
+ ) as HTMLSlotElement | undefined;
1706
+ const nodes = slot?.assignedNodes({ flatten: true }) ?? [];
1707
+
1708
+ // Si el header ya tiene un icono, lo usamos como destino.
1709
+ for (const node of nodes) {
1710
+ if (node.nodeType === Node.ELEMENT_NODE) {
1711
+ const el = node as Element;
1712
+ if (el.tagName === 'MONI-ICON' || el.tagName === 'SVG') {
1713
+ return el.getBoundingClientRect();
1714
+ }
1715
+ const nestedIcon = el.querySelector('moni-icon, svg');
1716
+ if (nestedIcon) {
1717
+ return nestedIcon.getBoundingClientRect();
1718
+ }
1719
+ }
1720
+ }
1721
+
1722
+ // Si no hay icono en el header, calculamos una posición a la
1723
+ // izquierda del texto, alineada verticalmente.
1724
+ const iconSize = 24;
1725
+ const gap = 12;
1726
+ return new DOMRect(
1727
+ textRect.left - iconSize - gap,
1728
+ textRect.top + textRect.height / 2 - iconSize / 2,
1729
+ iconSize,
1730
+ iconSize
1731
+ );
1732
+ }
1733
+
1734
+ private _findSolidBackground(el: Element): string | null {
1735
+ const elements = [el, ...Array.from(el.querySelectorAll('*'))];
1736
+ for (const e of elements) {
1737
+ const bg = window.getComputedStyle(e).backgroundColor;
1738
+ if (!this._isColorTransparent(bg)) {
1739
+ return bg;
1740
+ }
1741
+ }
1742
+ // Si es un Web Component, revisamos su shadowRoot de forma superficial
1743
+ if (el.shadowRoot) {
1744
+ const shadowElements = Array.from(el.shadowRoot.querySelectorAll('*'));
1745
+ for (const e of shadowElements) {
1746
+ const bg = window.getComputedStyle(e).backgroundColor;
1747
+ if (!this._isColorTransparent(bg)) {
1748
+ return bg;
1749
+ }
1750
+ }
1751
+ }
1752
+ return null;
1753
+ }
1754
+
1755
+ private _getTargetState(el: HTMLElement): TargetState {
1756
+ // Los Web Components interactivos exponen un host transparente. El morph
1757
+ // debe usar la superficie pintada real dentro de su Shadow DOM.
1758
+ const visualEl = this._resolveVisualTarget(el);
1759
+ const rect = visualEl.getBoundingClientRect();
1760
+
1761
+ let bg = window.getComputedStyle(visualEl).backgroundColor;
1762
+
1763
+ // Si es transparente, intentamos buscar en pseudo-elementos (usado en botones Material 3)
1764
+ if (this._isColorTransparent(bg)) {
1765
+ const beforeBg = window.getComputedStyle(visualEl, '::before').backgroundColor;
1766
+ if (!this._isColorTransparent(beforeBg)) {
1767
+ bg = beforeBg;
1768
+ } else {
1769
+ const afterBg = window.getComputedStyle(visualEl, '::after').backgroundColor;
1770
+ if (!this._isColorTransparent(afterBg)) {
1771
+ bg = afterBg;
1772
+ } else {
1773
+ // Buscamos el primer elemento con fondo sólido en el árbol del objetivo
1774
+ const solidBg = this._findSolidBackground(visualEl);
1775
+ if (solidBg) bg = solidBg;
1776
+ }
1777
+ }
1778
+ }
1779
+
1780
+ // Si sigue siendo transparente, evitamos que el modal se vuelva transparente
1781
+ // leyendo su propio color computado actual para que mantenga su opacidad.
1782
+ if (this._isColorTransparent(bg) && this._panel) {
1783
+ bg = window.getComputedStyle(this._panel).backgroundColor;
1784
+ }
1785
+
1786
+ const computed = window.getComputedStyle(visualEl);
1787
+
1788
+ return {
1789
+ rect,
1790
+ backgroundColor: bg,
1791
+ color: computed.color || 'var(--on-surface)',
1792
+ borderRadius: computed.borderRadius || '1.25rem',
1793
+ boxShadow: computed.boxShadow || 'none'
1794
+ };
1795
+ }
1796
+
1797
+ private _resolveVisualTarget(el: HTMLElement): HTMLElement {
1798
+ if (el.tagName === 'MONI-BUTTON') {
1799
+ return (el.shadowRoot?.querySelector('.button') as HTMLElement | null) ?? el;
1800
+ }
1801
+ if (el.tagName === 'MONI-FAB') {
1802
+ return (el.shadowRoot?.querySelector('button') as HTMLElement | null) ?? el;
1803
+ }
1804
+ if (el.tagName === 'MONI-FAB-MENU') {
1805
+ const trigger = el.shadowRoot?.querySelector('.trigger') as HTMLElement | null;
1806
+ return (trigger?.shadowRoot?.querySelector('button') as HTMLElement | null) ?? trigger ?? el;
1807
+ }
1808
+ return el;
1809
+ }
1810
+
1811
+ /**
1812
+ * Crea una copia visual inerte del árbol compuesto del target. Los `<slot>`
1813
+ * se sustituyen por sus nodos asignados, por lo que la captura incluye tanto
1814
+ * contenido light DOM como superficies internas de Web Components.
1815
+ */
1816
+ private _createTargetSnapshot(source: HTMLElement, rect: DOMRect): HTMLElement {
1817
+ this._targetSnapshot?.remove();
1818
+ const snapshot = document.createElement('div');
1819
+ snapshot.className = 'morph-target-snapshot';
1820
+ snapshot.setAttribute('aria-hidden', 'true');
1821
+ const clone = this._cloneComposedNode(source);
1822
+ if (clone) {
1823
+ snapshot.appendChild(clone);
1824
+ // La geometría, el color y la elevación pertenecen al panel animado.
1825
+ // Repetirlos en el snapshot crea una segunda píldora encima del morph.
1826
+ // En medios visuales, en cambio, la propia superficie es contenido.
1827
+ if (
1828
+ clone instanceof HTMLElement &&
1829
+ !['IMG', 'VIDEO', 'CANVAS', 'SVG', 'PICTURE'].includes(source.tagName)
1830
+ ) {
1831
+ Object.assign(clone.style, {
1832
+ background: 'transparent',
1833
+ backgroundColor: 'transparent',
1834
+ boxShadow: 'none',
1835
+ borderColor: 'transparent',
1836
+ outline: 'none',
1837
+ position: 'relative',
1838
+ inset: 'auto',
1839
+ transform: 'none',
1840
+ opacity: '1',
1841
+ visibility: 'visible',
1842
+ width: '100%',
1843
+ height: '100%'
1844
+ });
1845
+ if (source.tagName === 'BUTTON') {
1846
+ clone.style.whiteSpace = 'nowrap';
1847
+ clone.querySelectorAll<HTMLElement>('span, [part~="label"]').forEach((child) => {
1848
+ child.style.whiteSpace = 'nowrap';
1849
+ });
1850
+ }
1851
+ }
1852
+ }
1853
+ Object.assign(snapshot.style, {
1854
+ width: `${rect.width}px`,
1855
+ height: `${rect.height}px`,
1856
+ overflow: 'hidden',
1857
+ pointerEvents: 'none'
1858
+ });
1859
+ this._targetSnapshot = snapshot;
1860
+ return snapshot;
1861
+ }
1862
+
1863
+ private _cloneComposedNode(source: Node): Node | null {
1864
+ if (source.nodeType === Node.TEXT_NODE) return source.cloneNode(false);
1865
+ if (!(source instanceof Element)) return null;
1866
+
1867
+ if (source instanceof HTMLSlotElement) {
1868
+ const fragment = document.createDocumentFragment();
1869
+ const nodes = source.assignedNodes({ flatten: true });
1870
+ for (const node of nodes.length ? nodes : Array.from(source.childNodes)) {
1871
+ const child = this._cloneComposedNode(node);
1872
+ if (child) fragment.appendChild(child);
1873
+ }
1874
+ return fragment;
1875
+ }
1876
+
1877
+ // No clonamos directamente un Custom Element con Shadow DOM: su
1878
+ // constructor crearía una segunda sombra y duplicaría el render. Una
1879
+ // superficie neutra recibe en cambio el árbol visual ya compuesto.
1880
+ const clone = (source.shadowRoot
1881
+ ? document.createElement('div')
1882
+ : source.cloneNode(false)) as HTMLElement;
1883
+ if (source instanceof HTMLImageElement && clone instanceof HTMLImageElement) {
1884
+ // El snapshot vive menos de medio segundo: no puede depender de que el
1885
+ // lazy-loader observe nuevamente una imagen que ya estaba renderizada.
1886
+ clone.loading = 'eager';
1887
+ clone.decoding = 'sync';
1888
+ if (source.currentSrc) clone.src = source.currentSrc;
1889
+ }
1890
+ clone.removeAttribute('id');
1891
+ clone.removeAttribute('name');
1892
+ clone.setAttribute('aria-hidden', 'true');
1893
+ const computed = window.getComputedStyle(source);
1894
+ for (let index = 0; index < computed.length; index += 1) {
1895
+ const property = computed.item(index);
1896
+ clone.style.setProperty(property, computed.getPropertyValue(property), computed.getPropertyPriority(property));
1897
+ }
1898
+
1899
+ const children = source.shadowRoot
1900
+ ? Array.from(source.shadowRoot.childNodes).filter(
1901
+ (node) => !(node instanceof HTMLStyleElement)
1902
+ )
1903
+ : Array.from(source.childNodes);
1904
+ for (const node of children) {
1905
+ const child = this._cloneComposedNode(node);
1906
+ if (child) clone.appendChild(child);
1907
+ }
1908
+ return clone;
1909
+ }
1910
+
1911
+ private _isColorTransparent(color: string | null | undefined): boolean {
1912
+ if (!color) return true;
1913
+ const c = color.replace(/\s/g, '').toLowerCase();
1914
+ return (
1915
+ c === 'rgba(0,0,0,0)' ||
1916
+ c === 'transparent' ||
1917
+ c === '' ||
1918
+ c.includes(',0)') ||
1919
+ c.includes('/0)')
1920
+ );
1921
+ }
1922
+
1923
+ private _readTransformState(el: HTMLElement): {
1924
+ rotation: number;
1925
+ skewX: number;
1926
+ scaleX: number;
1927
+ scaleY: number;
1928
+ } {
1929
+ const transform = window.getComputedStyle(el).transform;
1930
+ if (!transform || transform === 'none') {
1931
+ return { rotation: 0, skewX: 0, scaleX: 1, scaleY: 1 };
1932
+ }
1933
+ try {
1934
+ const values = transform.match(/matrix(?:3d)?\(([^)]+)\)/)?.[1]
1935
+ .split(',')
1936
+ .map((value) => Number.parseFloat(value.trim()));
1937
+ let a: number;
1938
+ let b: number;
1939
+ let c: number;
1940
+ let d: number;
1941
+ if (values?.length === 6) {
1942
+ [a, b, c, d] = values;
1943
+ } else if (values?.length === 16) {
1944
+ a = values[0];
1945
+ b = values[1];
1946
+ c = values[4];
1947
+ d = values[5];
1948
+ } else {
1949
+ const matrix = new DOMMatrix(transform);
1950
+ ({ a, b, c, d } = matrix);
1951
+ }
1952
+ const scaleX = Math.hypot(a, b) || 1;
1953
+ const determinant = a * d - b * c;
1954
+ const scaleY = determinant / scaleX || 1;
1955
+ return {
1956
+ rotation: Math.atan2(b, a) * (180 / Math.PI),
1957
+ skewX:
1958
+ Math.atan2(a * c + b * d, scaleX * scaleX) *
1959
+ (180 / Math.PI),
1960
+ scaleX,
1961
+ scaleY
1962
+ };
1963
+ } catch {
1964
+ return { rotation: 0, skewX: 0, scaleX: 1, scaleY: 1 };
1965
+ }
1966
+ }
1967
+
1968
+ private _applyRect(el: HTMLElement, rect: DOMRect): void {
1969
+ el.style.top = `${rect.top}px`;
1970
+ el.style.left = `${rect.left}px`;
1971
+ el.style.width = `${rect.width}px`;
1972
+ el.style.height = `${rect.height}px`;
1973
+ }
1974
+
1911
1975
  private _usesFullscreen(): boolean {
1912
1976
  if (this.fullscreen) return true;
1913
1977
  return this.responsiveFullscreen && typeof window !== 'undefined' && window.innerWidth <= this.fullscreenBreakpoint;
@@ -1918,185 +1982,232 @@ export class MoniMorphModal extends MoniElement {
1918
1982
  const vh = window.innerHeight;
1919
1983
  if (this._usesFullscreen()) return new DOMRect(0, 0, vw, vh);
1920
1984
  const padding = 16;
1921
-
1922
- let width = this.autoSize && naturalSize ? naturalSize.width : this._parseSize(this.expandedWidth, vw);
1923
- let height = this.autoSize && naturalSize ? naturalSize.height : this._parseSize(this.expandedHeight, vh);
1924
-
1925
- width = Math.min(width, vw - padding * 2);
1926
- height = Math.min(height, vh - padding * 2);
1927
-
1928
- let top = targetRect.top;
1929
- let left = targetRect.left;
1930
-
1931
- switch (this.placement) {
1932
- case 'viewport-center':
1933
- left = vw / 2 - width / 2;
1934
- top = vh / 2 - height / 2;
1935
- break;
1936
- case 'viewport-top':
1937
- left = vw / 2 - width / 2;
1938
- top = padding;
1939
- break;
1940
- case 'viewport-bottom':
1941
- left = vw / 2 - width / 2;
1942
- top = vh - height - padding;
1943
- break;
1944
- case 'viewport-left':
1945
- left = padding;
1946
- top = vh / 2 - height / 2;
1947
- break;
1948
- case 'viewport-right':
1949
- left = vw - width - padding;
1950
- top = vh / 2 - height / 2;
1951
- break;
1952
- case 'viewport-top-left':
1953
- left = padding;
1954
- top = padding;
1955
- break;
1956
- case 'viewport-top-right':
1957
- left = vw - width - padding;
1958
- top = padding;
1959
- break;
1960
- case 'viewport-bottom-left':
1961
- left = padding;
1962
- top = vh - height - padding;
1963
- break;
1964
- case 'viewport-bottom-right':
1965
- left = vw - width - padding;
1966
- top = vh - height - padding;
1967
- break;
1968
- case 'center':
1969
- left = targetRect.left + targetRect.width / 2 - width / 2;
1970
- top = targetRect.top + targetRect.height / 2 - height / 2;
1971
- break;
1972
- case 'top':
1973
- left = targetRect.left + targetRect.width / 2 - width / 2;
1974
- top = this.coverTarget
1975
- ? targetRect.top + targetRect.height - height
1976
- : targetRect.top - height;
1977
- break;
1978
- case 'top-start':
1979
- left = targetRect.left + targetRect.width - width;
1980
- top = this.coverTarget
1981
- ? targetRect.top + targetRect.height - height
1982
- : targetRect.top - height;
1983
- break;
1984
- case 'top-end':
1985
- left = targetRect.left;
1986
- top = this.coverTarget
1987
- ? targetRect.top + targetRect.height - height
1988
- : targetRect.top - height;
1989
- break;
1990
- case 'bottom':
1991
- left = targetRect.left + targetRect.width / 2 - width / 2;
1992
- top = this.coverTarget
1993
- ? targetRect.top
1994
- : targetRect.top + targetRect.height;
1995
- break;
1996
- case 'bottom-start':
1997
- left = targetRect.left + targetRect.width - width;
1998
- top = this.coverTarget
1999
- ? targetRect.top
2000
- : targetRect.top + targetRect.height;
2001
- break;
2002
- case 'bottom-end':
2003
- left = targetRect.left;
2004
- top = this.coverTarget
2005
- ? targetRect.top
2006
- : targetRect.top + targetRect.height;
2007
- break;
2008
- case 'left':
2009
- left = this.coverTarget
2010
- ? targetRect.left + targetRect.width - width
2011
- : targetRect.left - width;
2012
- top = targetRect.top + targetRect.height / 2 - height / 2;
2013
- break;
2014
- case 'left-start':
2015
- left = this.coverTarget
2016
- ? targetRect.left + targetRect.width - width
2017
- : targetRect.left - width;
2018
- top = targetRect.top + targetRect.height - height;
2019
- break;
2020
- case 'left-end':
2021
- left = this.coverTarget
2022
- ? targetRect.left + targetRect.width - width
2023
- : targetRect.left - width;
2024
- top = targetRect.top;
2025
- break;
2026
- case 'right':
2027
- left = this.coverTarget
2028
- ? targetRect.left
2029
- : targetRect.left + targetRect.width;
2030
- top = targetRect.top + targetRect.height / 2 - height / 2;
2031
- break;
2032
- case 'right-start':
2033
- left = this.coverTarget
2034
- ? targetRect.left
2035
- : targetRect.left + targetRect.width;
2036
- top = targetRect.top + targetRect.height - height;
2037
- break;
2038
- case 'right-end':
2039
- left = this.coverTarget
2040
- ? targetRect.left
2041
- : targetRect.left + targetRect.width;
2042
- top = targetRect.top;
2043
- break;
2044
- }
2045
-
2046
- left = Math.max(padding, Math.min(left, vw - width - padding));
2047
- top = Math.max(padding, Math.min(top, vh - height - padding));
2048
-
1985
+
1986
+ const configuredWidth = this._parseSize(this.expandedWidth, vw, 'width');
1987
+ const configuredHeight = this._parseSize(this.expandedHeight, vh, 'height');
1988
+ let width = this.autoSize && naturalSize ? naturalSize.width : configuredWidth;
1989
+ let height = (this.autoSize || this.autoHeight) && naturalSize
1990
+ ? Math.min(naturalSize.height, configuredHeight)
1991
+ : configuredHeight;
1992
+
1993
+ width = Math.min(width, vw - padding * 2);
1994
+ height = Math.min(height, vh - padding * 2);
1995
+
1996
+ let top = targetRect.top;
1997
+ let left = targetRect.left;
1998
+
1999
+ switch (this.placement) {
2000
+ case 'viewport-center':
2001
+ left = vw / 2 - width / 2;
2002
+ top = vh / 2 - height / 2;
2003
+ break;
2004
+ case 'viewport-top':
2005
+ left = vw / 2 - width / 2;
2006
+ top = padding;
2007
+ break;
2008
+ case 'viewport-bottom':
2009
+ left = vw / 2 - width / 2;
2010
+ top = vh - height - padding;
2011
+ break;
2012
+ case 'viewport-left':
2013
+ left = padding;
2014
+ top = vh / 2 - height / 2;
2015
+ break;
2016
+ case 'viewport-right':
2017
+ left = vw - width - padding;
2018
+ top = vh / 2 - height / 2;
2019
+ break;
2020
+ case 'viewport-top-left':
2021
+ left = padding;
2022
+ top = padding;
2023
+ break;
2024
+ case 'viewport-top-right':
2025
+ left = vw - width - padding;
2026
+ top = padding;
2027
+ break;
2028
+ case 'viewport-bottom-left':
2029
+ left = padding;
2030
+ top = vh - height - padding;
2031
+ break;
2032
+ case 'viewport-bottom-right':
2033
+ left = vw - width - padding;
2034
+ top = vh - height - padding;
2035
+ break;
2036
+ case 'center':
2037
+ left = targetRect.left + targetRect.width / 2 - width / 2;
2038
+ top = targetRect.top + targetRect.height / 2 - height / 2;
2039
+ break;
2040
+ case 'top':
2041
+ left = targetRect.left + targetRect.width / 2 - width / 2;
2042
+ top = this.coverTarget
2043
+ ? targetRect.top + targetRect.height - height
2044
+ : targetRect.top - height;
2045
+ break;
2046
+ case 'top-start':
2047
+ left = targetRect.left + targetRect.width - width;
2048
+ top = this.coverTarget
2049
+ ? targetRect.top + targetRect.height - height
2050
+ : targetRect.top - height;
2051
+ break;
2052
+ case 'top-end':
2053
+ left = targetRect.left;
2054
+ top = this.coverTarget
2055
+ ? targetRect.top + targetRect.height - height
2056
+ : targetRect.top - height;
2057
+ break;
2058
+ case 'bottom':
2059
+ left = targetRect.left + targetRect.width / 2 - width / 2;
2060
+ top = this.coverTarget
2061
+ ? targetRect.top
2062
+ : targetRect.top + targetRect.height;
2063
+ break;
2064
+ case 'bottom-start':
2065
+ left = targetRect.left + targetRect.width - width;
2066
+ top = this.coverTarget
2067
+ ? targetRect.top
2068
+ : targetRect.top + targetRect.height;
2069
+ break;
2070
+ case 'bottom-end':
2071
+ left = targetRect.left;
2072
+ top = this.coverTarget
2073
+ ? targetRect.top
2074
+ : targetRect.top + targetRect.height;
2075
+ break;
2076
+ case 'left':
2077
+ left = this.coverTarget
2078
+ ? targetRect.left + targetRect.width - width
2079
+ : targetRect.left - width;
2080
+ top = targetRect.top + targetRect.height / 2 - height / 2;
2081
+ break;
2082
+ case 'left-start':
2083
+ left = this.coverTarget
2084
+ ? targetRect.left + targetRect.width - width
2085
+ : targetRect.left - width;
2086
+ top = targetRect.top + targetRect.height - height;
2087
+ break;
2088
+ case 'left-end':
2089
+ left = this.coverTarget
2090
+ ? targetRect.left + targetRect.width - width
2091
+ : targetRect.left - width;
2092
+ top = targetRect.top;
2093
+ break;
2094
+ case 'right':
2095
+ left = this.coverTarget
2096
+ ? targetRect.left
2097
+ : targetRect.left + targetRect.width;
2098
+ top = targetRect.top + targetRect.height / 2 - height / 2;
2099
+ break;
2100
+ case 'right-start':
2101
+ left = this.coverTarget
2102
+ ? targetRect.left
2103
+ : targetRect.left + targetRect.width;
2104
+ top = targetRect.top + targetRect.height - height;
2105
+ break;
2106
+ case 'right-end':
2107
+ left = this.coverTarget
2108
+ ? targetRect.left
2109
+ : targetRect.left + targetRect.width;
2110
+ top = targetRect.top;
2111
+ break;
2112
+ }
2113
+
2114
+ left = Math.max(padding, Math.min(left, vw - width - padding));
2115
+ top = Math.max(padding, Math.min(top, vh - height - padding));
2116
+
2049
2117
  return new DOMRect(left, top, width, height);
2050
2118
  }
2051
2119
 
2052
- private _parseSize(value: string, ref: number): number {
2053
- const num = parseFloat(value);
2054
- if (Number.isNaN(num)) return 352;
2055
- if (value.endsWith('px')) return num;
2056
- if (value.endsWith('rem')) return num * 16;
2057
- if (value.endsWith('em')) return num * 16;
2058
- if (value.endsWith('%')) return (num / 100) * ref;
2059
- if (value.endsWith('vw')) return (num / 100) * window.innerWidth;
2060
- if (value.endsWith('vh')) return (num / 100) * window.innerHeight;
2061
- return num;
2062
- }
2063
-
2064
- static override styles = [
2065
- sharedStyles,
2066
- css`
2067
- :host {
2068
- display: contents;
2069
- }
2070
-
2071
- .backdrop {
2072
- position: fixed;
2073
- inset: 0;
2074
- background-color: var(--scrim);
2075
- opacity: 0;
2076
- visibility: hidden;
2077
- transition:
2078
- opacity var(--_backdrop-duration, 0.35s) var(--_backdrop-ease, cubic-bezier(0.2, 0, 0, 1)),
2079
- visibility var(--_backdrop-duration, 0.35s) var(--_backdrop-ease, cubic-bezier(0.2, 0, 0, 1));
2080
- z-index: calc(var(--_z-index, 100) - 1);
2081
- }
2082
-
2083
- .backdrop.open {
2084
- opacity: 1;
2085
- visibility: visible;
2086
- }
2120
+ private _measureNaturalSize(panel: HTMLElement): { width: number; height: number } {
2121
+ const prevW = panel.style.getPropertyValue('--_panel-width');
2122
+ const prevH = panel.style.getPropertyValue('--_panel-height');
2123
+ const prevPanelW = panel.style.width;
2124
+ const prevPanelH = panel.style.height;
2125
+ const bodyEl = this.shadowRoot!.querySelector('.body') as HTMLElement;
2126
+ const prevOverflow = bodyEl?.style.overflow ?? '';
2127
+ const configuredWidth = Math.min(
2128
+ this._parseSize(this.expandedWidth, window.innerWidth, 'width'),
2129
+ window.innerWidth - 32
2130
+ );
2087
2131
 
2088
- .panel {
2089
- position: fixed;
2090
- visibility: hidden;
2091
- opacity: 0;
2092
- pointer-events: none;
2093
- display: flex;
2094
- flex-direction: column;
2095
- overflow: hidden;
2096
- background-color: var(--surface-container-high);
2097
- color: var(--on-surface);
2098
- border-radius: var(--moni-morph-panel-radius, 1.75rem);
2099
- box-shadow: var(--elevate2);
2132
+ panel.style.width = this.autoSize ? 'auto' : `${configuredWidth}px`;
2133
+ panel.style.height = 'auto';
2134
+ panel.style.setProperty('--_panel-width', this.autoSize ? 'max-content' : `${configuredWidth}px`);
2135
+ panel.style.setProperty('--_panel-height', 'max-content');
2136
+ if (bodyEl) bodyEl.style.overflow = 'visible';
2137
+ const size = { width: this._inner.scrollWidth, height: this._inner.scrollHeight };
2138
+
2139
+ panel.style.setProperty('--_panel-width', prevW);
2140
+ panel.style.setProperty('--_panel-height', prevH);
2141
+ panel.style.width = prevPanelW;
2142
+ panel.style.height = prevPanelH;
2143
+ if (bodyEl) bodyEl.style.overflow = prevOverflow;
2144
+ return size;
2145
+ }
2146
+
2147
+ private _parseSize(value: string, ref: number, axis: 'width' | 'height'): number {
2148
+ const normalized = value.trim();
2149
+ if (/^(calc|min|max|clamp)\(/i.test(normalized) && CSS.supports(axis, normalized)) {
2150
+ const probe = document.createElement('div');
2151
+ Object.assign(probe.style, {
2152
+ position: 'fixed',
2153
+ visibility: 'hidden',
2154
+ pointerEvents: 'none',
2155
+ contain: 'strict',
2156
+ width: axis === 'width' ? normalized : '0',
2157
+ height: axis === 'height' ? normalized : '0'
2158
+ });
2159
+ document.body.appendChild(probe);
2160
+ const measured = probe.getBoundingClientRect()[axis];
2161
+ probe.remove();
2162
+ if (measured > 0) return measured;
2163
+ }
2164
+ const num = parseFloat(normalized);
2165
+ if (Number.isNaN(num)) return 352;
2166
+ if (normalized.endsWith('px')) return num;
2167
+ if (normalized.endsWith('rem')) return num * 16;
2168
+ if (normalized.endsWith('em')) return num * 16;
2169
+ if (normalized.endsWith('%')) return (num / 100) * ref;
2170
+ if (normalized.endsWith('vw')) return (num / 100) * window.innerWidth;
2171
+ if (normalized.endsWith('vh')) return (num / 100) * window.innerHeight;
2172
+ return num;
2173
+ }
2174
+
2175
+ static override styles = [
2176
+ sharedStyles,
2177
+ css`
2178
+ :host {
2179
+ display: contents;
2180
+ }
2181
+
2182
+ .backdrop {
2183
+ position: fixed;
2184
+ inset: 0;
2185
+ background-color: var(--scrim);
2186
+ opacity: 0;
2187
+ visibility: hidden;
2188
+ transition:
2189
+ opacity var(--_backdrop-duration, 0.35s) var(--_backdrop-ease, cubic-bezier(0.2, 0, 0, 1)),
2190
+ visibility var(--_backdrop-duration, 0.35s) var(--_backdrop-ease, cubic-bezier(0.2, 0, 0, 1));
2191
+ z-index: calc(var(--_z-index, 100) - 1);
2192
+ }
2193
+
2194
+ .backdrop.open {
2195
+ opacity: 1;
2196
+ visibility: visible;
2197
+ }
2198
+
2199
+ .panel {
2200
+ position: fixed;
2201
+ visibility: hidden;
2202
+ opacity: 0;
2203
+ pointer-events: none;
2204
+ display: flex;
2205
+ flex-direction: column;
2206
+ overflow: hidden;
2207
+ background-color: var(--surface-container-high);
2208
+ color: var(--on-surface);
2209
+ border-radius: var(--moni-morph-panel-radius, 1.75rem);
2210
+ box-shadow: var(--elevate2);
2100
2211
  z-index: var(--_z-index, 100);
2101
2212
  }
2102
2213
 
@@ -2112,164 +2223,165 @@ export class MoniMorphModal extends MoniElement {
2112
2223
  .panel.fullscreen footer {
2113
2224
  padding-block-end: max(1.5rem, env(safe-area-inset-bottom));
2114
2225
  }
2115
-
2116
- .panel-inner {
2117
- display: flex;
2118
- flex-direction: column;
2119
- width: var(--_panel-width, 100%);
2120
- height: var(--_panel-height, 100%);
2121
- flex-shrink: 0;
2122
- overflow: hidden;
2123
- }
2124
-
2125
- header {
2126
- display: flex;
2127
- align-items: center;
2128
- justify-content: space-between;
2129
- gap: 0.75rem;
2130
- padding: 1.25rem 1.5rem 0.75rem;
2131
- min-block-size: 3.5rem;
2132
- }
2133
-
2134
- header.is-empty {
2135
- display: none;
2136
- }
2137
-
2226
+
2227
+ .panel-inner {
2228
+ display: flex;
2229
+ flex-direction: column;
2230
+ width: var(--_panel-width, 100%);
2231
+ height: var(--_panel-height, 100%);
2232
+ flex-shrink: 0;
2233
+ overflow: hidden;
2234
+ }
2235
+
2236
+ header {
2237
+ display: flex;
2238
+ align-items: center;
2239
+ justify-content: space-between;
2240
+ gap: 0.75rem;
2241
+ padding: 1.25rem 1.5rem 0.75rem;
2242
+ min-block-size: 3.5rem;
2243
+ }
2244
+
2245
+ header.is-empty {
2246
+ display: none;
2247
+ }
2248
+
2138
2249
  .body {
2139
2250
  flex: 1;
2140
2251
  overflow-y: var(--moni-morph-body-overflow-y, auto);
2141
2252
  overflow-x: var(--moni-morph-body-overflow-x, hidden);
2142
- padding: var(--moni-morph-body-padding, 0 1.5rem);
2143
- scrollbar-gutter: var(--moni-morph-body-scrollbar-gutter, stable);
2144
- }
2145
-
2146
- .panel.is-animating .body {
2147
- overflow: hidden !important;
2148
- }
2149
-
2150
- .panel.is-animating .body::-webkit-scrollbar {
2151
- display: none;
2152
- }
2153
-
2154
- footer {
2155
- display: flex;
2156
- align-items: center;
2157
- justify-content: flex-end;
2158
- gap: 0.5rem;
2159
- padding: 1rem 1.5rem 1.5rem;
2160
- border-top: 0.0625rem solid var(--outline-variant);
2161
- }
2162
-
2163
- footer.is-empty {
2164
- display: none;
2165
- }
2166
-
2167
- .close-btn {
2168
- display: inline-flex;
2169
- align-items: center;
2170
- justify-content: center;
2171
- inline-size: 2.5rem;
2172
- block-size: 2.5rem;
2173
- padding: 0;
2174
- border: none;
2175
- border-radius: 50%;
2176
- background: transparent;
2177
- color: var(--on-surface-variant);
2178
- cursor: pointer;
2179
- font-size: 1.25rem;
2180
- }
2181
-
2182
- .close-btn:hover {
2183
- background-color: var(--active);
2184
- }
2185
-
2186
- .morph-text,
2187
- .morph-icon {
2188
- position: fixed;
2189
- pointer-events: none;
2190
- box-sizing: border-box;
2191
- overflow: hidden;
2192
- will-change: transform, width, height, color, font-size;
2193
- }
2194
-
2195
- .trigger-label-slot {
2196
- display: none;
2197
- }
2198
- `
2199
- ];
2200
-
2201
- /**
2202
- * Renderiza el caparazón del modal morph: fondo opcional + contenedor de panel.
2203
- *
2204
- * **Bandera `_visible`:**
2205
- * `visible = this._visible || this.open` mantiene vivo el DOM mientras se reproduce
2206
- * la animación de cierre de GSAP. `_visible` se establece en `true` al inicio de la apertura y
2207
- * en `false` solo después de que se dispara el `onComplete` de la animación de cierre. Sin esto,
2208
- * Lit eliminaría el panel del DOM antes de que la animación termine.
2209
- *
2210
- * **Condicional del fondo (backdrop):**
2211
- * El div `.backdrop` solo se renderiza cuando `hasBackdrop=true`. Cuando `modal=false`,
2212
- * el `background-color` del fondo se establece en `transparent` para que el fondo superpuesto
2213
- * (scrim) sea invisible, pero los eventos del puntero aún se consumen (clic para cerrar).
2214
- *
2215
- * **Objetivo de morphing de GSAP:**
2216
- * `.panel` es el objetivo de la animación GSAP. Al abrir: se anima desde el
2217
- * `getBoundingClientRect()` del elemento anclaje (posición + tamaño) a su tamaño CSS natural
2218
- * mediante `gsap.fromTo()`. Al cerrar: a la inversa. `.panel-inner` recibe una
2219
- * contra-escala para evitar que el contenido interno se escale junto con el panel,
2220
- * creando una ilusión física de "expandirse desde el origen".
2221
- */
2253
+ overscroll-behavior: contain;
2254
+ padding: var(--moni-morph-body-padding, 0 1.5rem);
2255
+ scrollbar-gutter: var(--moni-morph-body-scrollbar-gutter, stable);
2256
+ }
2257
+
2258
+ .panel.is-animating .body {
2259
+ overflow: hidden !important;
2260
+ }
2261
+
2262
+ .panel.is-animating .body::-webkit-scrollbar {
2263
+ display: none;
2264
+ }
2265
+
2266
+ footer {
2267
+ display: flex;
2268
+ align-items: center;
2269
+ justify-content: flex-end;
2270
+ gap: 0.5rem;
2271
+ padding: 1rem 1.5rem 1.5rem;
2272
+ border-top: 0.0625rem solid var(--outline-variant);
2273
+ }
2274
+
2275
+ footer.is-empty {
2276
+ display: none;
2277
+ }
2278
+
2279
+ .close-btn {
2280
+ display: inline-flex;
2281
+ align-items: center;
2282
+ justify-content: center;
2283
+ inline-size: 2.5rem;
2284
+ block-size: 2.5rem;
2285
+ padding: 0;
2286
+ border: none;
2287
+ border-radius: 50%;
2288
+ background: transparent;
2289
+ color: var(--on-surface-variant);
2290
+ cursor: pointer;
2291
+ font-size: 1.25rem;
2292
+ }
2293
+
2294
+ .close-btn:hover {
2295
+ background-color: var(--active);
2296
+ }
2297
+
2298
+ .morph-text,
2299
+ .morph-icon {
2300
+ position: fixed;
2301
+ pointer-events: none;
2302
+ box-sizing: border-box;
2303
+ overflow: hidden;
2304
+ will-change: transform, width, height, color, font-size;
2305
+ }
2306
+
2307
+ .trigger-label-slot {
2308
+ display: none;
2309
+ }
2310
+ `
2311
+ ];
2312
+
2313
+ /**
2314
+ * Renderiza el caparazón del modal morph: fondo opcional + contenedor de panel.
2315
+ *
2316
+ * **Bandera `_visible`:**
2317
+ * `visible = this._visible || this.open` mantiene vivo el DOM mientras se reproduce
2318
+ * la animación de cierre de GSAP. `_visible` se establece en `true` al inicio de la apertura y
2319
+ * en `false` solo después de que se dispara el `onComplete` de la animación de cierre. Sin esto,
2320
+ * Lit eliminaría el panel del DOM antes de que la animación termine.
2321
+ *
2322
+ * **Condicional del fondo (backdrop):**
2323
+ * El div `.backdrop` solo se renderiza cuando `hasBackdrop=true`. Cuando `modal=false`,
2324
+ * el `background-color` del fondo se establece en `transparent` para que el fondo superpuesto
2325
+ * (scrim) sea invisible, pero los eventos del puntero aún se consumen (clic para cerrar).
2326
+ *
2327
+ * **Objetivo de morphing de GSAP:**
2328
+ * `.panel` es el objetivo de la animación GSAP. Al abrir: se anima desde el
2329
+ * `getBoundingClientRect()` del elemento anclaje (posición + tamaño) a su tamaño CSS natural
2330
+ * mediante `gsap.fromTo()`. Al cerrar: a la inversa. `.panel-inner` recibe una
2331
+ * contra-escala para evitar que el contenido interno se escale junto con el panel,
2332
+ * creando una ilusión física de "expandirse desde el origen".
2333
+ */
2222
2334
  override render() {
2223
2335
  const visible = this._visible || this.open;
2224
2336
  const headerEmpty = !this._hasHeader && !this.showCloseButton;
2225
2337
  const fullscreen = this._usesFullscreen();
2226
- return html`
2227
- ${this.hasBackdrop ? html`
2228
- <div
2229
- class="backdrop ${visible ? 'open' : ''}"
2230
- part="backdrop"
2231
- style="${!this.modal ? 'background-color: transparent;' : ''}"
2232
- ></div>
2233
- ` : ''}
2338
+ return html`
2339
+ ${this.hasBackdrop ? html`
2340
+ <div
2341
+ class="backdrop ${visible ? 'open' : ''}"
2342
+ part="backdrop"
2343
+ style="${!this.modal ? 'background-color: transparent;' : ''}"
2344
+ ></div>
2345
+ ` : ''}
2234
2346
  <div class="panel ${fullscreen ? 'fullscreen' : ''}" part="panel">
2235
- <div class="panel-inner" part="panel-inner">
2236
- <header
2237
- class="${headerEmpty ? 'is-empty' : ''}"
2238
- part="header"
2239
- >
2240
- <div class="header-morph-wrapper"><slot name="header" @slotchange=${this._onHeaderSlotChange}></slot></div>
2241
- ${this.showCloseButton
2242
- ? html`
2243
- <button
2244
- class="close-btn"
2245
- @click=${this.hide}
2246
- aria-label="Cerrar"
2247
- >
2248
- <moni-icon name="close"></moni-icon>
2249
- </button>
2250
- `
2251
- : ''}
2252
- </header>
2253
- <div class="body" part="body"><slot></slot></div>
2254
- <footer
2255
- class="${this._hasFooter ? '' : 'is-empty'}"
2256
- part="footer"
2257
- >
2258
- <slot name="footer" @slotchange=${this._onFooterSlotChange}></slot>
2259
- </footer>
2260
- </div>
2261
- </div>
2262
- <div class="trigger-label-slot">
2263
- <slot name="trigger-label" @slotchange=${this._onTriggerLabelSlotChange}></slot>
2264
- </div>
2265
- `;
2266
- }
2267
- }
2268
-
2269
- declare global {
2270
- interface HTMLElementTagNameMap {
2271
- 'moni-morph-modal': MoniMorphModal;
2272
- }
2273
- }
2274
-
2275
- export default MoniMorphModal;
2347
+ <div class="panel-inner" part="panel-inner">
2348
+ <header
2349
+ class="${headerEmpty ? 'is-empty' : ''}"
2350
+ part="header"
2351
+ >
2352
+ <div class="header-morph-wrapper"><slot name="header" @slotchange=${this._onHeaderSlotChange}></slot></div>
2353
+ ${this.showCloseButton
2354
+ ? html`
2355
+ <button
2356
+ class="close-btn"
2357
+ @click=${this.hide}
2358
+ aria-label="Cerrar"
2359
+ >
2360
+ <moni-icon name="close"></moni-icon>
2361
+ </button>
2362
+ `
2363
+ : ''}
2364
+ </header>
2365
+ <div class="body" part="body"><slot></slot></div>
2366
+ <footer
2367
+ class="${this._hasFooter ? '' : 'is-empty'}"
2368
+ part="footer"
2369
+ >
2370
+ <slot name="footer" @slotchange=${this._onFooterSlotChange}></slot>
2371
+ </footer>
2372
+ </div>
2373
+ </div>
2374
+ <div class="trigger-label-slot">
2375
+ <slot name="trigger-label" @slotchange=${this._onTriggerLabelSlotChange}></slot>
2376
+ </div>
2377
+ `;
2378
+ }
2379
+ }
2380
+
2381
+ declare global {
2382
+ interface HTMLElementTagNameMap {
2383
+ 'moni-morph-modal': MoniMorphModal;
2384
+ }
2385
+ }
2386
+
2387
+ export default MoniMorphModal;