@dotrino/install 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/install",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Botón de \"Instalar app\" (PWA) unificado del ecosistema Dotrino: Web Component <dotrino-install> que captura beforeinstallprompt temprano, maneja iOS con un modal de instrucciones (sin alert) y se oculta si ya está instalada. Vue o vanilla. Sin JS de terceros ni cookies, bilingüe es/en.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.d.ts CHANGED
@@ -1,11 +1,39 @@
1
- export type InstallOutcome = 'accepted' | 'dismissed' | 'instructions' | 'installed'
1
+ export type InstallOutcome = 'accepted' | 'dismissed' | 'instructions' | 'installed' | 'relaunch'
2
+ export type InstallContext = 'installed' | 'native' | 'ios' | 'relaunch' | 'none'
2
3
 
3
4
  /** ¿La app ya corre como instalada (display-mode standalone) o se instaló ya? */
4
5
  export function isAppInstalled(): boolean
5
6
 
7
+ /** Atajo legible de `isAppInstalled()`. */
8
+ export function isStandalone(): boolean
9
+
6
10
  /** Detección de iOS/iPadOS (incluye el iPad que se reporta como Mac). */
7
11
  export function isIOS(): boolean
8
12
 
13
+ /** Detección de Android. */
14
+ export function isAndroid(): boolean
15
+
16
+ /** Nombre del parámetro marcador ("pwa-install"). */
17
+ export const INSTALL_PARAM: string
18
+
19
+ /** ¿La URL trae el marcador `?pwa-install=1` (tras relanzar en Chrome)? */
20
+ export function hasInstallFlag(param?: string): boolean
21
+
22
+ /** ¿La app fue abierta desde el hub instalado (el home le puso `?hub=1`)? */
23
+ export function isEmbeddedHub(): boolean
24
+
25
+ /**
26
+ * `intent://` que reabre la app en Chrome (no el Custom Tab/webview embebido)
27
+ * con `?pwa-install=1`, con fallback a https. Solo útil en Android. null si falla.
28
+ */
29
+ export function chromeInstallUrl(param?: string): string | null
30
+
31
+ /**
32
+ * Estado de cara a la UI: 'installed' | 'native' (prompt nativo) | 'ios'
33
+ * (instrucciones Safari) | 'relaunch' (Android embebido → abrir en Chrome) | 'none'.
34
+ */
35
+ export function installContext(): InstallContext
36
+
9
37
  /** ¿Tiene sentido ofrecer instalar? (prompt nativo disponible, o iOS, y no instalada). */
10
38
  export function canInstall(): boolean
11
39
 
@@ -26,8 +54,11 @@ export const HOME_DEFAULT: string
26
54
 
27
55
  /**
28
56
  * Custom element del botón de instalar (`<dotrino-install>`).
29
- * Atributos: `lang` ("es"|"en"), `label` (texto), `icon` ("false" oculta el icono).
30
- * Eventos: `cc-install` (cancelable), `cc-install-result` (detail.outcome).
57
+ * Atributos: `lang` ("es"|"en"), `label` (texto), `icon` ("false" oculta el icono),
58
+ * `app-name` y `app-icon` (título e icono del overlay grande de instalación).
59
+ * En Android dentro de un Custom Tab (sin prompt nativo) el botón relanza la app
60
+ * en Chrome; al llegar con `?pwa-install=1` muestra un overlay grande centrado.
61
+ * Eventos: `cc-install` (cancelable), `cc-install-result` (detail.outcome, incluye 'relaunch').
31
62
  */
32
63
  export class DotrinoInstall extends HTMLElement {
33
64
  /** Dispara la instalación desde JS (igual que el click del usuario). */
package/src/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * recurrentes que aquí se arreglan de una vez:
8
8
  *
9
9
  * 1. El evento `beforeinstallprompt` se dispara MUY pronto (a veces antes de
10
- * montar el componente). Si lo escuchás dentro de onMounted lo perdés y el
10
+ * montar el componente). Si lo escuchas dentro de onMounted lo pierdes y el
11
11
  * botón nunca aparece. Aquí lo capturamos a nivel de módulo, en import.
12
12
  * 2. iOS/Safari NO dispara `beforeinstallprompt` y no hay API de instalación:
13
13
  * la única vía es "Compartir → Añadir a pantalla de inicio". Mostramos esas
@@ -24,7 +24,7 @@
24
24
  * <dotrino-install></dotrino-install>
25
25
  * <dotrino-install lang="en" label="Install"></dotrino-install>
26
26
  *
27
- * Uso programático (si querés tu propio botón) — ver también ./vue:
27
+ * Uso programático (si quieres tu propio botón) — ver también ./vue:
28
28
  * import { canInstall, promptInstall, onInstallStateChange } from '@dotrino/install'
29
29
  * if (canInstall()) showMyButton()
30
30
  * await promptInstall() // dispara el prompt nativo o el modal iOS/fallback
@@ -41,6 +41,7 @@ const HOME_DEFAULT = 'https://dotrino.com'
41
41
 
42
42
  let _deferred = null // el BeforeInstallPromptEvent diferido (o null)
43
43
  let _installed = false // se marcó appinstalled en esta sesión
44
+ let _settled = false // pasó el margen de espera del prompt nativo
44
45
  const _subs = new Set() // suscriptores a cambios de estado (re-render)
45
46
 
46
47
  function _emit () {
@@ -49,6 +50,13 @@ function _emit () {
49
50
  }
50
51
  }
51
52
 
53
+ // Tras un margen sin prompt nativo (Android), asumimos contexto embebido (Custom
54
+ // Tab abierto desde otra PWA): ahí Chrome NO dispara `beforeinstallprompt`, así
55
+ // que en vez de ocultar el botón ofreceremos relanzar en Chrome (ver más abajo).
56
+ if (typeof window !== 'undefined') {
57
+ try { setTimeout(() => { _settled = true; _emit() }, 1400) } catch (_) {}
58
+ }
59
+
52
60
  function _onBIP (e) {
53
61
  // Evita que el navegador muestre su mini-infobar; nosotros decidimos cuándo.
54
62
  try { e.preventDefault() } catch (_) {}
@@ -91,6 +99,70 @@ export function isIOS () {
91
99
  return false
92
100
  }
93
101
 
102
+ /** ¿Corre como app instalada (standalone)? Atajo legible. */
103
+ export function isStandalone () {
104
+ return isAppInstalled()
105
+ }
106
+
107
+ /** Detección de Android. */
108
+ export function isAndroid () {
109
+ try { return /Android/i.test(navigator.userAgent || '') } catch (_) { return false }
110
+ }
111
+
112
+ const INSTALL_PARAM = 'pwa-install'
113
+ const HUB_PARAM = 'hub'
114
+
115
+ /** ¿La URL trae el marcador de "abrir para instalar" (tras relanzar en Chrome)? */
116
+ export function hasInstallFlag (param = INSTALL_PARAM) {
117
+ try { return new URLSearchParams(location.search).has(param) } catch (_) { return false }
118
+ }
119
+
120
+ /**
121
+ * ¿La app fue abierta DESDE el hub instalado (el home le puso `?hub=1`)? En ese
122
+ * caso corre embebida en un Custom Tab que puede reportarse como standalone; este
123
+ * marcador es la señal fiable de "estoy embebido, ofrece instalar en Chrome".
124
+ */
125
+ export function isEmbeddedHub () {
126
+ try { return new URLSearchParams(location.search).has(HUB_PARAM) } catch (_) { return false }
127
+ }
128
+
129
+ /**
130
+ * Construye un `intent://` que reabre ESTA misma app en **Chrome** (no en el
131
+ * Custom Tab/webview embebido) con el marcador `?pwa-install=1`, con fallback a
132
+ * la URL https normal si Chrome no está. Solo tiene sentido en Android.
133
+ */
134
+ export function chromeInstallUrl (param = INSTALL_PARAM) {
135
+ try {
136
+ const u = new URL(location.href)
137
+ u.searchParams.delete(HUB_PARAM) // el marcador del hub no debe viajar a Chrome
138
+ u.searchParams.set(param, '1')
139
+ const target = `${u.host}${u.pathname}${u.search}`
140
+ const fallback = encodeURIComponent(u.toString())
141
+ return `intent://${target}#Intent;scheme=https;package=com.android.chrome;S.browser_fallback_url=${fallback};end`
142
+ } catch (_) { return null }
143
+ }
144
+
145
+ /**
146
+ * Estado de instalación de cara a la UI:
147
+ * - 'installed' ya instalada / standalone → no ofrecer.
148
+ * - 'native' hay prompt nativo (Chrome instalable) → instalar de un toque.
149
+ * - 'ios' iOS/Safari → instrucciones "Añadir a pantalla de inicio".
150
+ * - 'relaunch' Android sin prompt nativo (probable Custom Tab embebido) →
151
+ * relanzar en Chrome con `chromeInstallUrl()`.
152
+ * - 'none' nada que ofrecer (todavía esperando, o navegador sin soporte).
153
+ */
154
+ export function installContext () {
155
+ // Prompt nativo (Chrome real) siempre gana.
156
+ if (_deferred) return 'native'
157
+ // Embebido desde el hub (Android): ofrecer relanzar en Chrome AUNQUE el Custom
158
+ // Tab se reporte como standalone (por eso esto va ANTES de isAppInstalled()).
159
+ if (isEmbeddedHub() && isAndroid()) return 'relaunch'
160
+ if (isAppInstalled()) return 'installed'
161
+ if (isIOS()) return 'ios'
162
+ if (_settled && isAndroid()) return 'relaunch'
163
+ return 'none'
164
+ }
165
+
94
166
  /**
95
167
  * ¿Tiene sentido ofrecer instalar? true si hay prompt nativo disponible, o si es
96
168
  * iOS (instalable a mano vía Compartir), siempre que NO esté ya instalada.
@@ -153,7 +225,12 @@ const I18N = {
153
225
  iosStep2: 'Elige «Añadir a pantalla de inicio»',
154
226
  otherIntro: 'Tu navegador no permite la instalación con un toque. Para instalarla:',
155
227
  otherStep: 'Abre el menú del navegador y elige «Instalar app» (o «Añadir a pantalla de inicio»).',
156
- close: 'Cerrar'
228
+ close: 'Cerrar',
229
+ bigTitle: 'Instala la app',
230
+ bigTitleNamed: (n) => `Instala ${n}`,
231
+ bigSub: 'Acceso directo en tu pantalla de inicio, sin tienda de apps.',
232
+ preparing: 'Preparando…',
233
+ notNow: 'Ahora no'
157
234
  },
158
235
  en: {
159
236
  install: 'Install App',
@@ -163,7 +240,12 @@ const I18N = {
163
240
  iosStep2: 'Choose “Add to Home Screen”',
164
241
  otherIntro: 'Your browser can’t install with one tap. To install it:',
165
242
  otherStep: 'Open the browser menu and choose “Install app” (or “Add to Home Screen”).',
166
- close: 'Close'
243
+ close: 'Close',
244
+ bigTitle: 'Install the app',
245
+ bigTitleNamed: (n) => `Install ${n}`,
246
+ bigSub: 'A shortcut on your home screen, no app store.',
247
+ preparing: 'Preparing…',
248
+ notNow: 'Not now'
167
249
  }
168
250
  }
169
251
 
@@ -263,25 +345,47 @@ const STYLE = `
263
345
  flex: none; width: 24px; height: 24px; border-radius: 50%;
264
346
  display: inline-flex; align-items: center; justify-content: center;
265
347
  font-size: 13px; font-weight: 700;
266
- background: var(--cc-install-accent, #84cc16); color: #14110f;
348
+ background: var(--cc-install-accent, #84cc16); color: var(--cc-install-accent-color, #14110f);
267
349
  }
268
350
  .steps svg { width: 20px; height: 20px; flex: none; }
269
351
  .card .ok {
270
352
  all: unset; box-sizing: border-box; cursor: pointer;
271
353
  display: block; width: 100%; text-align: center;
272
354
  padding: 11px; border-radius: 12px; font-weight: 700;
273
- background: var(--cc-install-accent, #84cc16); color: #14110f;
355
+ background: var(--cc-install-accent, #84cc16); color: var(--cc-install-accent-color, #14110f);
274
356
  }
275
357
  .card .ok:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
358
+
359
+ /* Overlay grande y centrado: aparece al llegar con ?pwa-install=1 (relanzado en Chrome). */
360
+ .big { text-align: center; }
361
+ .big .app-icon { width: 72px; height: 72px; border-radius: 18px; margin: 2px auto 14px; display: block; }
362
+ .big h2 { font-size: 20px; }
363
+ .big .sub { margin: 0 0 18px; opacity: .8; }
364
+ .big .cta {
365
+ all: unset; box-sizing: border-box; cursor: pointer;
366
+ display: flex; align-items: center; justify-content: center; gap: 8px;
367
+ width: 100%; padding: 14px; border-radius: 14px; font-weight: 800; font-size: 16px;
368
+ background: var(--cc-install-accent, #84cc16); color: var(--cc-install-accent-color, #14110f);
369
+ }
370
+ .big .cta[disabled] { opacity: .6; cursor: default; }
371
+ .big .cta svg { width: 20px; height: 20px; }
372
+ .big .manual { margin: 14px 2px 0; font-size: 14px; opacity: .85; }
373
+ .big .dismiss {
374
+ all: unset; box-sizing: border-box; cursor: pointer;
375
+ display: block; width: 100%; text-align: center;
376
+ margin-top: 10px; padding: 8px; font-weight: 600; opacity: .7;
377
+ }
276
378
  `
277
379
 
278
380
  class DotrinoInstall extends HTMLElement {
279
- static get observedAttributes () { return ['lang', 'label', 'icon'] }
381
+ static get observedAttributes () { return ['lang', 'label', 'icon', 'app-name', 'app-icon'] }
280
382
 
281
383
  constructor () {
282
384
  super()
283
385
  this.attachShadow({ mode: 'open' })
284
- this._modalOpen = false
386
+ this._modalOpen = false // modal de instrucciones (iOS/fallback)
387
+ this._bigOpen = false // overlay grande de instalación (?pwa-install=1)
388
+ this._bigManual = false // el overlay grande pasó a instrucciones manuales
285
389
  this._unsub = null
286
390
  this._onState = this._render.bind(this)
287
391
  this._onKey = this._onKey.bind(this)
@@ -289,12 +393,52 @@ class DotrinoInstall extends HTMLElement {
289
393
 
290
394
  connectedCallback () {
291
395
  this._unsub = onInstallStateChange(this._onState)
396
+ // Si llegamos con el marcador (relanzados en Chrome para instalar), abrimos
397
+ // el overlay grande centrado. Solo el primero monta el overlay (singleton).
398
+ if (hasInstallFlag() && !isAppInstalled() && !DotrinoInstall._bigClaimed) {
399
+ DotrinoInstall._bigClaimed = true
400
+ this._bigOpen = true
401
+ try { document.addEventListener('keydown', this._onKey) } catch (_) {}
402
+ // Si en unos segundos no hay prompt nativo, ofrecemos la vía manual.
403
+ setTimeout(() => {
404
+ if (this._bigOpen && !hasNativePrompt() && !isAppInstalled()) { this._bigManual = true; this._render() }
405
+ }, 4000)
406
+ }
292
407
  this._render()
293
408
  }
294
409
 
295
410
  disconnectedCallback () {
296
411
  if (this._unsub) { this._unsub(); this._unsub = null }
297
412
  try { document.removeEventListener('keydown', this._onKey) } catch (_) {}
413
+ if (this._portal) { try { this._portal.remove() } catch (_) {} this._portal = null; this._portalShadow = null }
414
+ }
415
+
416
+ /* Los modales (instrucciones / overlay grande) usan position:fixed para
417
+ centrarse en la ventana. Si el <dotrino-install> vive dentro de un ancestro
418
+ con `backdrop-filter`/`transform` (topbars), ese ancestro se vuelve el bloque
419
+ contenedor del fixed y el modal se descoloca. Por eso los renderizamos en un
420
+ PORTAL colgado de <body>, con su propio shadow root y el tema copiado. */
421
+ _portalRoot () {
422
+ if (!this._portal) {
423
+ this._portal = document.createElement('div')
424
+ this._portal.setAttribute('data-dotrino-install-portal', '')
425
+ this._portalShadow = this._portal.attachShadow({ mode: 'open' })
426
+ try {
427
+ const cs = getComputedStyle(this)
428
+ for (const v of ['--cc-install-accent', '--cc-install-modal-bg', '--cc-install-modal-color']) {
429
+ const val = cs.getPropertyValue(v)
430
+ if (val && val.trim()) this._portal.style.setProperty(v, val.trim())
431
+ }
432
+ } catch (_) {}
433
+ document.body.appendChild(this._portal)
434
+ }
435
+ return this._portalShadow
436
+ }
437
+
438
+ _renderPortal (innerHTML) {
439
+ const root = this._portalRoot()
440
+ root.innerHTML = innerHTML ? `<style>${STYLE}</style>${innerHTML}` : ''
441
+ return root
298
442
  }
299
443
 
300
444
  attributeChangedCallback () {
@@ -302,39 +446,68 @@ class DotrinoInstall extends HTMLElement {
302
446
  }
303
447
 
304
448
  _render () {
305
- const show = canInstall()
306
- // Oculta el host por completo cuando no hay nada que ofrecer (no ocupa espacio).
307
- this.hidden = !show
308
- if (!show && !this._modalOpen) {
309
- this.shadowRoot.innerHTML = ''
310
- return
311
- }
449
+ const ctx = installContext() // installed|native|ios|relaunch|none
450
+ const showBtn = ctx === 'native' || ctx === 'ios' || ctx === 'relaunch'
451
+ // Oculta el host por completo cuando no hay botón ni modal abierto.
452
+ this.hidden = !showBtn && !this._modalOpen && !this._bigOpen
453
+ if (this.hidden) { this.shadowRoot.innerHTML = ''; return }
312
454
 
313
455
  const lang = resolveLang(this.getAttribute('lang'))
314
456
  const t = I18N[lang]
315
457
  const label = this.getAttribute('label') != null ? this.getAttribute('label') : t.install
316
458
  const showIcon = (this.getAttribute('icon') || '').toLowerCase() !== 'false'
317
459
 
460
+ // Botón pequeño en el shadow propio (inline en la topbar).
318
461
  let html = `<style>${STYLE}</style>`
319
- html += `<button class="trigger" type="button" part="button" aria-label="${label || t.install}">`
320
- if (showIcon) html += `<span class="ico" part="icon">${ICON_DOWNLOAD}</span>`
321
- html += `<span class="lbl" part="label">${label}</span></button>`
322
-
323
- if (this._modalOpen) html += this._modalHTML(lang)
324
-
462
+ if (showBtn) {
463
+ html += `<button class="trigger" type="button" part="button" aria-label="${label || t.install}">`
464
+ if (showIcon) html += `<span class="ico" part="icon">${ICON_DOWNLOAD}</span>`
465
+ html += `<span class="lbl" part="label">${label}</span></button>`
466
+ }
325
467
  this.shadowRoot.innerHTML = html
326
468
  const btn = this.shadowRoot.querySelector('button.trigger')
327
- if (btn) btn.addEventListener('click', () => this._activate())
328
-
329
- if (this._modalOpen) {
330
- const close = () => this._closeModal()
331
- const backdrop = this.shadowRoot.querySelector('.backdrop')
332
- const okBtn = this.shadowRoot.querySelector('.ok')
333
- if (backdrop) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close() })
334
- if (okBtn) okBtn.addEventListener('click', close)
469
+ if (btn) btn.addEventListener('click', () => this._activate(ctx))
470
+
471
+ // Modales en el PORTAL (body), para que el fixed se centre en la ventana.
472
+ if (this._bigOpen) {
473
+ const root = this._renderPortal(this._bigHTML(lang))
474
+ const backdrop = root.querySelector('.backdrop')
475
+ if (backdrop) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) this._closeBig() })
476
+ root.querySelector('.dismiss')?.addEventListener('click', () => this._closeBig())
477
+ root.querySelector('.cta')?.addEventListener('click', () => this._bigInstall())
478
+ } else if (this._modalOpen) {
479
+ const root = this._renderPortal(this._modalHTML(lang))
480
+ const backdrop = root.querySelector('.backdrop')
481
+ const okBtn = root.querySelector('.ok')
482
+ if (backdrop) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) this._closeModal() })
483
+ if (okBtn) okBtn.addEventListener('click', () => this._closeModal())
484
+ } else if (this._portalShadow) {
485
+ this._renderPortal('') // limpia el portal cuando no hay modal
335
486
  }
336
487
  }
337
488
 
489
+ /** Overlay grande y centrado de instalación (al llegar con ?pwa-install=1). */
490
+ _bigHTML (lang) {
491
+ const t = I18N[lang]
492
+ const name = this.getAttribute('app-name')
493
+ const icon = this.getAttribute('app-icon')
494
+ const title = name ? t.bigTitleNamed(name) : t.bigTitle
495
+ const ready = hasNativePrompt()
496
+ let body
497
+ if (this._bigManual) {
498
+ const step = isIOS() ? `${t.iosStep1} → ${t.iosStep2}` : t.otherStep
499
+ body = `<p class="manual">${step}</p>`
500
+ } else {
501
+ body = `<button class="cta" type="button" ${ready ? '' : 'disabled'}>${ICON_DOWNLOAD}` +
502
+ `<span>${ready ? t.install : t.preparing}</span></button>`
503
+ }
504
+ return `<div class="backdrop" part="modal" role="dialog" aria-modal="true" aria-label="${title}">` +
505
+ `<div class="card big" part="modal-card">` +
506
+ (icon ? `<img class="app-icon" src="${icon}" alt="" width="72" height="72" />` : '') +
507
+ `<h2>${title}</h2><p class="sub">${t.bigSub}</p>${body}` +
508
+ `<button class="dismiss" type="button">${t.notNow}</button></div></div>`
509
+ }
510
+
338
511
  _modalHTML (lang) {
339
512
  const t = I18N[lang]
340
513
  let steps
@@ -350,10 +523,19 @@ class DotrinoInstall extends HTMLElement {
350
523
  `<button class="ok" type="button">${t.close}</button></div></div>`
351
524
  }
352
525
 
353
- async _activate () {
526
+ async _activate (ctx) {
354
527
  const ev = new CustomEvent('cc-install', { bubbles: true, composed: true, cancelable: true })
355
528
  if (!this.dispatchEvent(ev)) return // la app canceló para hacer lo suyo
356
529
 
530
+ // Contexto embebido (Custom Tab desde otra PWA): no hay prompt nativo, así que
531
+ // relanzamos la app en Chrome, donde la instalación sí funciona.
532
+ if (ctx === 'relaunch') {
533
+ const url = chromeInstallUrl()
534
+ this.dispatchEvent(new CustomEvent('cc-install-result', { bubbles: true, composed: true, detail: { outcome: 'relaunch' } }))
535
+ if (url) { try { location.href = url } catch (_) {} }
536
+ return
537
+ }
538
+
357
539
  const outcome = await promptInstall()
358
540
  if (outcome === 'instructions') {
359
541
  this._openModal()
@@ -364,6 +546,22 @@ class DotrinoInstall extends HTMLElement {
364
546
  this._render()
365
547
  }
366
548
 
549
+ /** Botón grande del overlay: dispara el prompt nativo; si no hay, instrucciones. */
550
+ async _bigInstall () {
551
+ if (!hasNativePrompt()) { this._bigManual = true; this._render(); return }
552
+ const outcome = await promptInstall()
553
+ this.dispatchEvent(new CustomEvent('cc-install-result', { bubbles: true, composed: true, detail: { outcome } }))
554
+ if (outcome === 'accepted' || outcome === 'installed') this._closeBig()
555
+ else if (outcome === 'instructions') { this._bigManual = true; this._render() }
556
+ else this._render()
557
+ }
558
+
559
+ _closeBig () {
560
+ this._bigOpen = false
561
+ try { document.removeEventListener('keydown', this._onKey) } catch (_) {}
562
+ this._render()
563
+ }
564
+
367
565
  _openModal () {
368
566
  this._modalOpen = true
369
567
  try { document.addEventListener('keydown', this._onKey) } catch (_) {}
@@ -377,7 +575,9 @@ class DotrinoInstall extends HTMLElement {
377
575
  }
378
576
 
379
577
  _onKey (e) {
380
- if (e.key === 'Escape') this._closeModal()
578
+ if (e.key !== 'Escape') return
579
+ if (this._bigOpen) this._closeBig()
580
+ else this._closeModal()
381
581
  }
382
582
 
383
583
  /** Dispara la instalación desde JS de la app (igual que el click). */
@@ -388,4 +588,6 @@ if (typeof customElements !== 'undefined' && !customElements.get('dotrino-instal
388
588
  customElements.define('dotrino-install', DotrinoInstall)
389
589
  }
390
590
 
391
- export { DotrinoInstall, HOME_DEFAULT }
591
+ DotrinoInstall._bigClaimed = false
592
+
593
+ export { DotrinoInstall, HOME_DEFAULT, INSTALL_PARAM }