@edc-motor/ui 0.5.19 → 0.5.20

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": "@edc-motor/ui",
3
- "version": "0.5.19",
3
+ "version": "0.5.20",
4
4
  "description": "EdC Motor — componentes públicos Vue 3 + tokens SCSS para webs de juegos de mesa (paquete fuente: lo compila el consumidor con Vite)",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -53,7 +53,15 @@ export { default as LocaleSelector } from './components/LocaleSelector.vue'
53
53
  export { default as AppBreadcrumbs, type Crumb } from './components/AppBreadcrumbs.vue'
54
54
  export { default as PreviewGrid, type PreviewGridItem } from './components/PreviewGrid.vue'
55
55
  export { createApi, type CreateApiOptions } from './lib/createApi'
56
- export { watchSplash, dismissSplash, type WatchSplashOptions } from './lib/splash'
56
+ export {
57
+ watchSplash,
58
+ dismissSplash,
59
+ setupNavigationSplash,
60
+ type WatchSplashOptions,
61
+ type NavigationSplashOptions,
62
+ type SplashRouterLike,
63
+ } from './lib/splash'
64
+ export { readCache, writeCache } from './lib/localCache'
57
65
  export { useToast, type Toast } from './composables/useToast'
58
66
  export { useConfirm, type ConfirmOptions } from './composables/useConfirm'
59
67
  export { useTheme, type ThemeMode } from './composables/useTheme'
@@ -0,0 +1,23 @@
1
+ // Caché ligera en localStorage para el ARRANQUE de las SPAs: la última
2
+ // respuesta buena de settings/menús/locales se guarda y en la siguiente
3
+ // visita se pinta AL INSTANTE con ella mientras se refresca en segundo
4
+ // plano (patrón stale-while-revalidate). Todo va en try/catch: sin
5
+ // localStorage (incógnito estricto, cuota llena) simplemente no hay caché
6
+ // y la app funciona como siempre.
7
+
8
+ export function readCache<T>(key: string): T | null {
9
+ try {
10
+ const raw = localStorage.getItem(key)
11
+ return raw ? (JSON.parse(raw) as T) : null
12
+ } catch {
13
+ return null
14
+ }
15
+ }
16
+
17
+ export function writeCache(key: string, value: unknown): void {
18
+ try {
19
+ localStorage.setItem(key, JSON.stringify(value))
20
+ } catch {
21
+ // llena o bloqueada: da igual, la caché es solo una mejora
22
+ }
23
+ }
package/src/lib/splash.ts CHANGED
@@ -1,18 +1,21 @@
1
1
  import type { AxiosInstance } from 'axios'
2
2
 
3
- // Splash de arranque. La SPA sirve un cascarón instantáneo y pide TODO lo
4
- // real (settings, menús, sesión, contenido de la vista) a la API después de
5
- // montar: hasta que responde, cada componente pinta su estado por defecto.
6
- // En local (API a ~1 ms) ese fotograma provisional no llega a verse; en
7
- // producción sí. El remedio: el index.html estático trae un velo a pantalla
8
- // completa (#edc-splash, HTML autosuficiente pintado desde el fotograma
9
- // cero, antes incluso de descargar el bundle) y este módulo lo retira
10
- // cuando el arranque termina de verdad.
3
+ // Splash de arranque Y velo de navegación. La SPA sirve un cascarón
4
+ // instantáneo y pide TODO lo real (settings, menús, sesión, contenido de la
5
+ // vista) a la API después de montar: hasta que responde, cada componente
6
+ // pinta su estado por defecto. En local (API a ~1 ms) ese fotograma
7
+ // provisional no llega a verse; en producción sí. El remedio: el index.html
8
+ // estático trae un velo a pantalla completa (#edc-splash, HTML
9
+ // autosuficiente pintado desde el fotograma cero, antes incluso de
10
+ // descargar el bundle) y este módulo lo retira cuando el arranque termina
11
+ // de verdad — y lo REUTILIZA como estado de carga en las navegaciones SPA
12
+ // lentas (setupNavigationSplash): la identidad de carga es siempre el
13
+ // splash, nunca skeletons.
11
14
  //
12
15
  // ¿Y cuándo termina «de verdad»? En vez de instrumentar cada vista, se
13
- // observa el cliente axios: se cuentan las peticiones en vuelo y el splash
16
+ // observa el cliente axios: se cuentan las peticiones en vuelo y el velo
14
17
  // cae en el PRIMER REPOSO DE RED (cero peticiones durante `quietMs`). Eso
15
- // cubre solo la cascada inicial —locales → settings → datos de la primera
18
+ // cubre sola la cascada inicial —locales → settings → datos de la primera
16
19
  // vista, encadenados por microtareas que siempre ganan al temporizador— y
17
20
  // no exige tocar las vistas. Un tope (`maxWaitMs`) garantiza que una API
18
21
  // caída nunca deja el velo puesto.
@@ -21,9 +24,14 @@ import type { AxiosInstance } from 'axios'
21
24
  // del onMounted ya habrían salido sin contar):
22
25
  //
23
26
  // watchSplash({ api })
27
+ // setupNavigationSplash(router) // opcional: velo también al navegar
24
28
  // app.mount('#app')
29
+ //
30
+ // El index.html puede definir window.__edcSplashRefresh (p. ej. para
31
+ // re-elegir el logo según el idioma guardado): se invoca en cada re-show.
25
32
 
26
33
  const SPLASH_ID = 'edc-splash'
34
+ const DONE_CLASS = 'edc-splash--done'
27
35
 
28
36
  export interface WatchSplashOptions {
29
37
  /** Cliente(s) axios cuyas peticiones marcan el arranque (createApi). */
@@ -34,39 +42,75 @@ export interface WatchSplashOptions {
34
42
  maxWaitMs?: number
35
43
  }
36
44
 
37
- /** Retira el splash con su fundido (idempotente; sin splash, no hace nada). */
45
+ /** Contrato mínimo del router (estructural: sin depender de vue-router). */
46
+ export interface SplashRouterLike {
47
+ beforeEach(guard: () => void): unknown
48
+ afterEach(hook: () => void): unknown
49
+ onError?(handler: () => void): unknown
50
+ }
51
+
52
+ export interface NavigationSplashOptions {
53
+ /** Espera antes de ENSEÑAR el velo: una navegación que resuelve antes no
54
+ * lo ve ni un frame (ms). */
55
+ showDelayMs?: number
56
+ /** Tope duro por navegación (ms). */
57
+ maxWaitMs?: number
58
+ }
59
+
60
+ // ---- contador de red compartido (lo alimenta watchSplash) ---------------
61
+ let inflight = 0
62
+ let quietMs = 200
63
+ let quietTimer: ReturnType<typeof setTimeout> | undefined
64
+ // Qué hacer al llegar el reposo: el arranque y cada navegación lo reasignan.
65
+ let onQuiet: (() => void) | null = null
66
+
67
+ function armQuiet() {
68
+ clearTimeout(quietTimer)
69
+ quietTimer = setTimeout(() => onQuiet?.(), quietMs)
70
+ }
71
+
72
+ function splashEl(): HTMLElement | null {
73
+ return document.getElementById(SPLASH_ID)
74
+ }
75
+
76
+ /** Retira el splash con su fundido (idempotente; sin splash, no hace nada).
77
+ * El elemento se OCULTA, no se elimina: las navegaciones lo reutilizan. */
38
78
  export function dismissSplash(): void {
39
- const el = document.getElementById(SPLASH_ID)
40
- if (!el || el.classList.contains('edc-splash--done')) return
41
- el.classList.add('edc-splash--done')
42
- const remove = () => el.remove()
43
- el.addEventListener('transitionend', remove, { once: true })
44
- // Por si no hay transición (prefers-reduced-motion, CSS recortado).
45
- setTimeout(remove, 600)
79
+ const el = splashEl()
80
+ if (!el || el.classList.contains(DONE_CLASS)) return
81
+ el.classList.add(DONE_CLASS)
82
+ // visibility al terminar el fundido: para el pulso del logo mientras el
83
+ // velo no se ve (el guard evita apagar un re-show que haya interrumpido).
84
+ const finish = () => {
85
+ if (el.classList.contains(DONE_CLASS)) el.style.visibility = 'hidden'
86
+ }
87
+ el.addEventListener('transitionend', finish, { once: true })
88
+ setTimeout(finish, 600)
89
+ }
90
+
91
+ /** Re-enseña el velo (fundido de entrada por la misma transición). */
92
+ function showSplash(): void {
93
+ const el = splashEl()
94
+ if (!el) return
95
+ ;(window as unknown as { __edcSplashRefresh?: () => void }).__edcSplashRefresh?.()
96
+ el.style.visibility = ''
97
+ // reflow: sin él, quitar la clase en el mismo frame se salta la transición
98
+ void el.offsetWidth
99
+ el.classList.remove(DONE_CLASS)
46
100
  }
47
101
 
48
102
  /** Observa el arranque y retira el splash en el primer reposo de red. */
49
103
  export function watchSplash(options: WatchSplashOptions = {}): void {
50
104
  if (typeof document === 'undefined') return
51
- if (!document.getElementById(SPLASH_ID)) return
105
+ if (!splashEl()) return
52
106
 
53
- const quietMs = options.quietMs ?? 200
107
+ quietMs = options.quietMs ?? 200
54
108
  const maxWaitMs = options.maxWaitMs ?? 8000
55
109
  const apis = Array.isArray(options.api) ? options.api : options.api ? [options.api] : []
56
110
 
57
- let inflight = 0
58
- let quietTimer: ReturnType<typeof setTimeout> | undefined
59
-
60
- const done = () => {
61
- clearTimeout(quietTimer)
62
- // Doble rAF: el render definitivo llega a pintarse BAJO el velo antes
63
- // del fundido (sin esto el fundido podría destapar un frame a medias).
64
- requestAnimationFrame(() => requestAnimationFrame(dismissSplash))
65
- }
66
- const armQuiet = () => {
67
- clearTimeout(quietTimer)
68
- quietTimer = setTimeout(done, quietMs)
69
- }
111
+ // Doble rAF: el render definitivo llega a pintarse BAJO el velo antes
112
+ // del fundido (sin esto el fundido podría destapar un frame a medias).
113
+ onQuiet = () => requestAnimationFrame(() => requestAnimationFrame(dismissSplash))
70
114
 
71
115
  for (const api of apis) {
72
116
  api.interceptors.request.use((config) => {
@@ -91,3 +135,54 @@ export function watchSplash(options: WatchSplashOptions = {}): void {
91
135
  armQuiet()
92
136
  setTimeout(dismissSplash, maxWaitMs)
93
137
  }
138
+
139
+ /**
140
+ * Velo de carga en las navegaciones SPA: si tras `showDelayMs` la
141
+ * navegación sigue pendiente o hay peticiones en vuelo, el splash vuelve a
142
+ * cubrir la ventana (tapando el «negro» de la vista sin datos) y cae en el
143
+ * siguiente reposo de red. Una navegación instantánea no lo ve ni un frame.
144
+ * Requiere watchSplash({ api }) antes (es quien alimenta el contador).
145
+ */
146
+ export function setupNavigationSplash(
147
+ router: SplashRouterLike,
148
+ options: NavigationSplashOptions = {},
149
+ ): void {
150
+ if (typeof document === 'undefined') return
151
+ if (!splashEl()) return
152
+
153
+ const showDelayMs = options.showDelayMs ?? 120
154
+ const maxWaitMs = options.maxWaitMs ?? 8000
155
+
156
+ let navPending = false
157
+ let showTimer: ReturnType<typeof setTimeout> | undefined
158
+ let maxTimer: ReturnType<typeof setTimeout> | undefined
159
+
160
+ const hide = () => {
161
+ clearTimeout(showTimer)
162
+ clearTimeout(maxTimer)
163
+ requestAnimationFrame(() => requestAnimationFrame(dismissSplash))
164
+ }
165
+
166
+ router.beforeEach(() => {
167
+ navPending = true
168
+ onQuiet = hide
169
+ clearTimeout(showTimer)
170
+ showTimer = setTimeout(() => {
171
+ if (navPending || inflight > 0) showSplash()
172
+ }, showDelayMs)
173
+ clearTimeout(maxTimer)
174
+ maxTimer = setTimeout(hide, maxWaitMs)
175
+ })
176
+
177
+ router.afterEach(() => {
178
+ navPending = false
179
+ // Vista sin peticiones propias: el reposo ya está en curso → fundido.
180
+ if (inflight <= 0) armQuiet()
181
+ })
182
+
183
+ // Navegación abortada/errónea: no dejar el velo puesto.
184
+ router.onError?.(() => {
185
+ navPending = false
186
+ hide()
187
+ })
188
+ }