@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 +1 -1
- package/src/index.ts +9 -1
- package/src/lib/localCache.ts +23 -0
- package/src/lib/splash.ts +128 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edc-motor/ui",
|
|
3
|
-
"version": "0.5.
|
|
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 {
|
|
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
|
|
4
|
-
// real (settings, menús, sesión, contenido de la
|
|
5
|
-
// montar: hasta que responde, cada componente
|
|
6
|
-
// En local (API a ~1 ms) ese fotograma
|
|
7
|
-
// producción sí. El remedio: el index.html
|
|
8
|
-
// completa (#edc-splash, HTML
|
|
9
|
-
//
|
|
10
|
-
// cuando el arranque termina
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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 =
|
|
40
|
-
if (!el || el.classList.contains(
|
|
41
|
-
el.classList.add(
|
|
42
|
-
|
|
43
|
-
el
|
|
44
|
-
|
|
45
|
-
|
|
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 (!
|
|
105
|
+
if (!splashEl()) return
|
|
52
106
|
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
+
}
|