@edc-motor/ui 0.5.18 → 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.18",
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,6 +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 {
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'
56
65
  export { useToast, type Toast } from './composables/useToast'
57
66
  export { useConfirm, type ConfirmOptions } from './composables/useConfirm'
58
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
+ }
@@ -0,0 +1,188 @@
1
+ import type { AxiosInstance } from 'axios'
2
+
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.
14
+ //
15
+ // ¿Y cuándo termina «de verdad»? En vez de instrumentar cada vista, se
16
+ // observa el cliente axios: se cuentan las peticiones en vuelo y el velo
17
+ // cae en el PRIMER REPOSO DE RED (cero peticiones durante `quietMs`). Eso
18
+ // cubre sola la cascada inicial —locales → settings → datos de la primera
19
+ // vista, encadenados por microtareas que siempre ganan al temporizador— y
20
+ // no exige tocar las vistas. Un tope (`maxWaitMs`) garantiza que una API
21
+ // caída nunca deja el velo puesto.
22
+ //
23
+ // Uso (main.ts, ANTES de app.mount() — si se llama después, las peticiones
24
+ // del onMounted ya habrían salido sin contar):
25
+ //
26
+ // watchSplash({ api })
27
+ // setupNavigationSplash(router) // opcional: velo también al navegar
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.
32
+
33
+ const SPLASH_ID = 'edc-splash'
34
+ const DONE_CLASS = 'edc-splash--done'
35
+
36
+ export interface WatchSplashOptions {
37
+ /** Cliente(s) axios cuyas peticiones marcan el arranque (createApi). */
38
+ api?: AxiosInstance | AxiosInstance[]
39
+ /** Reposo de red que da el arranque por terminado (ms). */
40
+ quietMs?: number
41
+ /** Tope duro: el splash cae aunque la red siga ocupada o caída (ms). */
42
+ maxWaitMs?: number
43
+ }
44
+
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. */
78
+ export function dismissSplash(): void {
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)
100
+ }
101
+
102
+ /** Observa el arranque y retira el splash en el primer reposo de red. */
103
+ export function watchSplash(options: WatchSplashOptions = {}): void {
104
+ if (typeof document === 'undefined') return
105
+ if (!splashEl()) return
106
+
107
+ quietMs = options.quietMs ?? 200
108
+ const maxWaitMs = options.maxWaitMs ?? 8000
109
+ const apis = Array.isArray(options.api) ? options.api : options.api ? [options.api] : []
110
+
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))
114
+
115
+ for (const api of apis) {
116
+ api.interceptors.request.use((config) => {
117
+ inflight++
118
+ clearTimeout(quietTimer)
119
+ return config
120
+ })
121
+ api.interceptors.response.use(
122
+ (response) => {
123
+ if (--inflight <= 0) armQuiet()
124
+ return response
125
+ },
126
+ (error) => {
127
+ if (--inflight <= 0) armQuiet()
128
+ return Promise.reject(error)
129
+ },
130
+ )
131
+ }
132
+
133
+ // Armado inicial: si el arranque no llega a pedir nada (o no se pasó
134
+ // `api`), el splash cae solo tras el primer reposo.
135
+ armQuiet()
136
+ setTimeout(dismissSplash, maxWaitMs)
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
+ }