@edc-motor/ui 0.5.19 → 0.5.21

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.21",
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,16 @@ 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'
65
+ export { createSwrGet, type SwrGetter } from './lib/swr'
57
66
  export { useToast, type Toast } from './composables/useToast'
58
67
  export { useConfirm, type ConfirmOptions } from './composables/useConfirm'
59
68
  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,24 @@ 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'
35
+
36
+ // Peticiones DE FONDO: con `edcBackground: true` en el config de axios, la
37
+ // petición no cuenta para el velo — es relleno o refresco (catálogos con su
38
+ // propia presentación de carga, revalidaciones SWR, sondeos), no «la página
39
+ // aún no puede pintarse». Así el velo queda solo para las cargas de página.
40
+ declare module 'axios' {
41
+ interface AxiosRequestConfig {
42
+ edcBackground?: boolean
43
+ }
44
+ }
27
45
 
28
46
  export interface WatchSplashOptions {
29
47
  /** Cliente(s) axios cuyas peticiones marcan el arranque (createApi). */
@@ -34,53 +52,91 @@ export interface WatchSplashOptions {
34
52
  maxWaitMs?: number
35
53
  }
36
54
 
37
- /** Retira el splash con su fundido (idempotente; sin splash, no hace nada). */
55
+ /** Contrato mínimo del router (estructural: sin depender de vue-router). */
56
+ export interface SplashRouterLike {
57
+ beforeEach(guard: () => void): unknown
58
+ afterEach(hook: () => void): unknown
59
+ onError?(handler: () => void): unknown
60
+ }
61
+
62
+ export interface NavigationSplashOptions {
63
+ /** Espera antes de ENSEÑAR el velo: una navegación que resuelve antes no
64
+ * lo ve ni un frame (ms). */
65
+ showDelayMs?: number
66
+ /** Tope duro por navegación (ms). */
67
+ maxWaitMs?: number
68
+ }
69
+
70
+ // ---- contador de red compartido (lo alimenta watchSplash) ---------------
71
+ let inflight = 0
72
+ let quietMs = 200
73
+ let quietTimer: ReturnType<typeof setTimeout> | undefined
74
+ // Qué hacer al llegar el reposo: el arranque y cada navegación lo reasignan.
75
+ let onQuiet: (() => void) | null = null
76
+
77
+ function armQuiet() {
78
+ clearTimeout(quietTimer)
79
+ quietTimer = setTimeout(() => onQuiet?.(), quietMs)
80
+ }
81
+
82
+ function splashEl(): HTMLElement | null {
83
+ return document.getElementById(SPLASH_ID)
84
+ }
85
+
86
+ /** Retira el splash con su fundido (idempotente; sin splash, no hace nada).
87
+ * El elemento se OCULTA, no se elimina: las navegaciones lo reutilizan. */
38
88
  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)
89
+ const el = splashEl()
90
+ if (!el || el.classList.contains(DONE_CLASS)) return
91
+ el.classList.add(DONE_CLASS)
92
+ // visibility al terminar el fundido: para el pulso del logo mientras el
93
+ // velo no se ve (el guard evita apagar un re-show que haya interrumpido).
94
+ const finish = () => {
95
+ if (el.classList.contains(DONE_CLASS)) el.style.visibility = 'hidden'
96
+ }
97
+ el.addEventListener('transitionend', finish, { once: true })
98
+ setTimeout(finish, 600)
99
+ }
100
+
101
+ /** Re-enseña el velo (fundido de entrada por la misma transición). */
102
+ function showSplash(): void {
103
+ const el = splashEl()
104
+ if (!el) return
105
+ ;(window as unknown as { __edcSplashRefresh?: () => void }).__edcSplashRefresh?.()
106
+ el.style.visibility = ''
107
+ // reflow: sin él, quitar la clase en el mismo frame se salta la transición
108
+ void el.offsetWidth
109
+ el.classList.remove(DONE_CLASS)
46
110
  }
47
111
 
48
112
  /** Observa el arranque y retira el splash en el primer reposo de red. */
49
113
  export function watchSplash(options: WatchSplashOptions = {}): void {
50
114
  if (typeof document === 'undefined') return
51
- if (!document.getElementById(SPLASH_ID)) return
115
+ if (!splashEl()) return
52
116
 
53
- const quietMs = options.quietMs ?? 200
117
+ quietMs = options.quietMs ?? 200
54
118
  const maxWaitMs = options.maxWaitMs ?? 8000
55
119
  const apis = Array.isArray(options.api) ? options.api : options.api ? [options.api] : []
56
120
 
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
- }
121
+ // Doble rAF: el render definitivo llega a pintarse BAJO el velo antes
122
+ // del fundido (sin esto el fundido podría destapar un frame a medias).
123
+ onQuiet = () => requestAnimationFrame(() => requestAnimationFrame(dismissSplash))
70
124
 
71
125
  for (const api of apis) {
72
126
  api.interceptors.request.use((config) => {
73
- inflight++
74
- clearTimeout(quietTimer)
127
+ if (!config.edcBackground) {
128
+ inflight++
129
+ clearTimeout(quietTimer)
130
+ }
75
131
  return config
76
132
  })
77
133
  api.interceptors.response.use(
78
134
  (response) => {
79
- if (--inflight <= 0) armQuiet()
135
+ if (!response.config.edcBackground && --inflight <= 0) armQuiet()
80
136
  return response
81
137
  },
82
- (error) => {
83
- if (--inflight <= 0) armQuiet()
138
+ (error: { config?: { edcBackground?: boolean } }) => {
139
+ if (!error?.config?.edcBackground && --inflight <= 0) armQuiet()
84
140
  return Promise.reject(error)
85
141
  },
86
142
  )
@@ -91,3 +147,56 @@ export function watchSplash(options: WatchSplashOptions = {}): void {
91
147
  armQuiet()
92
148
  setTimeout(dismissSplash, maxWaitMs)
93
149
  }
150
+
151
+ /**
152
+ * Velo de carga en las navegaciones SPA: si tras `showDelayMs` la
153
+ * navegación sigue pendiente o hay peticiones en vuelo, el splash vuelve a
154
+ * cubrir la ventana (tapando el «negro» de la vista sin datos) y cae en el
155
+ * siguiente reposo de red. Una navegación instantánea no lo ve ni un frame.
156
+ * Requiere watchSplash({ api }) antes (es quien alimenta el contador).
157
+ */
158
+ export function setupNavigationSplash(
159
+ router: SplashRouterLike,
160
+ options: NavigationSplashOptions = {},
161
+ ): void {
162
+ if (typeof document === 'undefined') return
163
+ if (!splashEl()) return
164
+
165
+ // 250 ms: una API razonable responde antes y el velo ni aparece; por
166
+ // debajo saltaba en CADA navegación de producción (feo y alarmante).
167
+ const showDelayMs = options.showDelayMs ?? 250
168
+ const maxWaitMs = options.maxWaitMs ?? 8000
169
+
170
+ let navPending = false
171
+ let showTimer: ReturnType<typeof setTimeout> | undefined
172
+ let maxTimer: ReturnType<typeof setTimeout> | undefined
173
+
174
+ const hide = () => {
175
+ clearTimeout(showTimer)
176
+ clearTimeout(maxTimer)
177
+ requestAnimationFrame(() => requestAnimationFrame(dismissSplash))
178
+ }
179
+
180
+ router.beforeEach(() => {
181
+ navPending = true
182
+ onQuiet = hide
183
+ clearTimeout(showTimer)
184
+ showTimer = setTimeout(() => {
185
+ if (navPending || inflight > 0) showSplash()
186
+ }, showDelayMs)
187
+ clearTimeout(maxTimer)
188
+ maxTimer = setTimeout(hide, maxWaitMs)
189
+ })
190
+
191
+ router.afterEach(() => {
192
+ navPending = false
193
+ // Vista sin peticiones propias: el reposo ya está en curso → fundido.
194
+ if (inflight <= 0) armQuiet()
195
+ })
196
+
197
+ // Navegación abortada/errónea: no dejar el velo puesto.
198
+ router.onError?.(() => {
199
+ navPending = false
200
+ hide()
201
+ })
202
+ }
package/src/lib/swr.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { AxiosInstance, AxiosRequestConfig } from 'axios'
2
+
3
+ // Caché SWR en MEMORIA para los GET de contenido de la web pública
4
+ // (stale-while-revalidate): la primera visita a una URL espera a la red
5
+ // (y el velo de navegación puede aparecer); las siguientes se sirven de la
6
+ // memoria AL INSTANTE — sin velo, sin negro — mientras una revalidación DE
7
+ // FONDO (edcBackground: no cuenta para el velo) trae lo fresco y solo
8
+ // re-aplica si algo cambió. La memoria vive lo que la pestaña: nada que
9
+ // invalidar entre sesiones.
10
+ //
11
+ // Uso: const swrGet = createSwrGet(api) // una vez, junto al cliente
12
+ // await swrGet<Payload>('/pages/home', undefined, (data) => { ... })
13
+ // El callback puede llegar DOS veces (caché y luego fresco): debe ser
14
+ // idempotente y, si la vista pudo cambiar mientras tanto (requestId),
15
+ // comprobar dentro que sigue vigente.
16
+
17
+ export interface SwrGetter {
18
+ <T>(
19
+ url: string,
20
+ config: AxiosRequestConfig | undefined,
21
+ onData: (data: T, fresh: boolean) => void,
22
+ ): Promise<void>
23
+ /** Vacía la memoria (p. ej. al cerrar sesión, si el contenido depende de ella). */
24
+ clear(): void
25
+ }
26
+
27
+ export function createSwrGet(api: AxiosInstance): SwrGetter {
28
+ const memory = new Map<string, unknown>()
29
+
30
+ // La clave incluye los params por defecto del cliente (el `?locale` que
31
+ // inyecta el store de locales) además de los de la petición: la misma URL
32
+ // en otro idioma es OTRA entrada.
33
+ function keyFor(url: string, config?: AxiosRequestConfig): string {
34
+ const params: Record<string, unknown> = {
35
+ ...(api.defaults.params as Record<string, unknown> | undefined),
36
+ ...(config?.params as Record<string, unknown> | undefined),
37
+ }
38
+ const query = Object.keys(params)
39
+ .sort()
40
+ .map((k) => `${k}=${JSON.stringify(params[k])}`)
41
+ .join('&')
42
+ return `${url}?${query}`
43
+ }
44
+
45
+ async function run<T>(
46
+ url: string,
47
+ config: AxiosRequestConfig | undefined,
48
+ onData: (data: T, fresh: boolean) => void,
49
+ ): Promise<void> {
50
+ const key = keyFor(url, config)
51
+ if (memory.has(key)) {
52
+ const cached = memory.get(key) as T
53
+ onData(cached, false)
54
+ void api
55
+ .get<T>(url, { ...config, edcBackground: true })
56
+ .then(({ data }) => {
57
+ memory.set(key, data)
58
+ if (JSON.stringify(data) !== JSON.stringify(cached)) onData(data, true)
59
+ })
60
+ .catch(() => {
61
+ // la revalidación es oportunista: si falla, se queda lo cacheado
62
+ })
63
+ return
64
+ }
65
+ const { data } = await api.get<T>(url, config)
66
+ memory.set(key, data)
67
+ onData(data, true)
68
+ }
69
+
70
+ const getter = run as SwrGetter
71
+ getter.clear = () => memory.clear()
72
+ return getter
73
+ }