innoboxrr-form-core 2.4.0 → 2.5.0

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/README.md CHANGED
@@ -133,6 +133,38 @@ iconFor('plus') // 'lucide:plus'
133
133
  iconFor('mdi:home') // un nombre completo pasa tal cual
134
134
  ```
135
135
 
136
+ ## Avisos y confirmaciones
137
+
138
+ Estado de toda la aplicación, fuera del framework: quien avisa —un store, el
139
+ contrato de un modelo, la tabla— no necesita saber si detrás hay Vue o React.
140
+ Los paquetes de componentes lo pintan con su región de avisos y su anfitrión de
141
+ confirmaciones, que la aplicación monta una vez.
142
+
143
+ ```js
144
+ import { notify, notifySuccess, notifyError, confirmAction } from 'innoboxrr-form-core'
145
+
146
+ notifySuccess('Producto creado')
147
+ notifyError('No se pudo guardar', { title: 'Producto' })
148
+ notify({ message: 'Exportación en curso', variant: 'info', duration: 8000 })
149
+
150
+ if (await confirmAction({ message: '¿Borrar el producto?', variant: 'danger' })) {
151
+ // …
152
+ }
153
+ ```
154
+
155
+ - Un aviso se va a los cinco segundos; **uno de peligro se queda** hasta que se
156
+ cierra, porque quien no lo leyó a tiempo no sabría qué falló. `duration: 0` lo
157
+ deja fijo.
158
+ - No se apilan más de cinco: sale el más antiguo.
159
+ - `confirmAction` resuelve `true` o `false`. Una pregunta nueva con otra
160
+ pendiente da la anterior por cancelada.
161
+ - Sin un anfitrión montado, `confirmAction` usa `window.confirm`: una promesa que
162
+ no se resolviera dejaría colgada la acción que la espera.
163
+
164
+ Para pintarlos desde otro sitio: `getToasts`, `onToastsChange`, `dismiss`,
165
+ `getConfirmation`, `onConfirmationChange` y `resolveConfirmation`. `resetToasts`
166
+ y `resetConfirmation` vacían el estado entre pruebas.
167
+
136
168
  ## Archivos
137
169
 
138
170
  ```js
package/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * innoboxrr-form-core
3
3
  *
4
- * Lo que comparten innoboxrr-form-elements y innoboxrr-react-form-elements y
5
- * no depende de ningun framework: el tema, la validacion de archivos y la
6
- * lista de zonas horarias.
4
+ * Lo que comparten los paquetes de interfaz innoboxrr —los componentes de Vue
5
+ * y de React y los dos datatables— y no depende de ningun framework: el tema,
6
+ * los iconos, los avisos y confirmaciones, la validacion de archivos y la lista
7
+ * de zonas horarias.
7
8
  *
8
- * Estaba duplicado en los dos paquetes, que es exactamente como dos copias
9
+ * Estaba duplicado en los paquetes, que es exactamente como dos copias
9
10
  * empiezan a divergir.
10
11
  */
11
12
 
@@ -27,6 +28,21 @@ export {
27
28
  setIcons,
28
29
  } from './src/icons.js'
29
30
 
31
+ export {
32
+ confirmAction,
33
+ dismiss,
34
+ getConfirmation,
35
+ getToasts,
36
+ notify,
37
+ notifyError,
38
+ notifySuccess,
39
+ onConfirmationChange,
40
+ onToastsChange,
41
+ resetConfirmation,
42
+ resetToasts,
43
+ resolveConfirmation,
44
+ } from './src/feedback.js'
45
+
30
46
  export {
31
47
  FILE_ICON,
32
48
  describeFiles,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "innoboxrr-form-core",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "El nucleo agnostico de la interfaz innoboxrr: tema, iconos, estilos, archivos y zonas horarias.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,6 +8,7 @@
8
8
  ".": "./index.js",
9
9
  "./theme": "./src/theme.js",
10
10
  "./icons": "./src/icons.js",
11
+ "./feedback": "./src/feedback.js",
11
12
  "./files": "./src/files.js",
12
13
  "./timezone": "./src/timezone.js",
13
14
  "./styles": "./styles.css",
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Avisos y confirmaciones de toda la aplicación, como estado de módulo.
3
+ *
4
+ * Hasta ahora no había forma de avisar: un formulario generado se tragaba
5
+ * cualquier error que no fuera de validación, la tabla solo lo escribía en la
6
+ * consola y la confirmación de borrar era un SweetAlert de otro paquete. Cada
7
+ * pieza que quisiera decir algo tendría que haber montado su propio aviso.
8
+ *
9
+ * Vive aquí, fuera de Vue y de React, por la misma razón que el tema: los dos
10
+ * paquetes de componentes pintan la misma cola, y quien avisa —un store, un
11
+ * contrato de modelo, la tabla— no tiene por qué saber qué framework hay
12
+ * detrás.
13
+ *
14
+ * import { notify, confirmAction } from 'innoboxrr-form-core'
15
+ *
16
+ * notify({ message: 'Producto creado', variant: 'success' })
17
+ *
18
+ * if (await confirmAction({ message: '¿Borrar el producto?', variant: 'danger' })) {
19
+ * // …
20
+ * }
21
+ */
22
+
23
+ /**
24
+ * @typedef {'info'|'success'|'warning'|'danger'} Variant
25
+ * @typedef {{ id: number, title: string|null, message: string, variant: Variant, duration: number }} Toast
26
+ * @typedef {{ id: number, title: string, message: string, confirmLabel: string, cancelLabel: string, variant: 'primary'|'danger' }} Confirmation
27
+ */
28
+
29
+ const VARIANTS = ['info', 'success', 'warning', 'danger']
30
+
31
+ const DURATION = 5000
32
+
33
+ /**
34
+ * Más avisos a la vez no se leen: tapan la pantalla. El que sobra es el más
35
+ * antiguo.
36
+ */
37
+ const MAX_TOASTS = 5
38
+
39
+ // AVISOS
40
+
41
+ /** @type {Toast[]} */
42
+ let toasts = []
43
+
44
+ let nextToast = 1
45
+
46
+ /** @type {Map<number, ReturnType<typeof setTimeout>>} */
47
+ const timers = new Map()
48
+
49
+ /** @type {Set<(toasts: Toast[]) => void>} */
50
+ const toastListeners = new Set()
51
+
52
+ const emitToasts = () => toastListeners.forEach((listener) => listener(toasts))
53
+
54
+ /**
55
+ * Muestra un aviso y devuelve su id.
56
+ *
57
+ * Un aviso de peligro no se cierra solo por defecto: quien no lo leyó a tiempo
58
+ * no sabría qué falló. El resto se va a los cinco segundos. `duration: 0` lo
59
+ * deja hasta que se cierre a mano.
60
+ *
61
+ * @param {string|{ message: string, title?: string, variant?: Variant, duration?: number }} options
62
+ * @returns {number}
63
+ */
64
+ export function notify(options) {
65
+ const input = typeof options === 'string' ? { message: options } : (options ?? {})
66
+ const variant = VARIANTS.includes(input.variant) ? input.variant : 'info'
67
+ const duration = input.duration ?? (variant === 'danger' ? 0 : DURATION)
68
+
69
+ /** @type {Toast} */
70
+ const toast = {
71
+ id: nextToast++,
72
+ title: input.title ?? null,
73
+ message: String(input.message ?? ''),
74
+ variant,
75
+ duration,
76
+ }
77
+
78
+ // Se reemplaza el array en vez de mutarlo: React compara la instantánea
79
+ // por referencia y, con el mismo array, no repintaría.
80
+ toasts = [...toasts, toast]
81
+
82
+ while (toasts.length > MAX_TOASTS) {
83
+ forget(toasts[0].id)
84
+ toasts = toasts.slice(1)
85
+ }
86
+
87
+ if (duration > 0) {
88
+ timers.set(toast.id, setTimeout(() => dismiss(toast.id), duration))
89
+ }
90
+
91
+ emitToasts()
92
+
93
+ return toast.id
94
+ }
95
+
96
+ /**
97
+ * @param {string} message
98
+ * @param {{ title?: string, duration?: number }} [options]
99
+ */
100
+ export function notifySuccess(message, options = {}) {
101
+ return notify({ ...options, message, variant: 'success' })
102
+ }
103
+
104
+ /**
105
+ * @param {string} message
106
+ * @param {{ title?: string, duration?: number }} [options]
107
+ */
108
+ export function notifyError(message, options = {}) {
109
+ return notify({ ...options, message, variant: 'danger' })
110
+ }
111
+
112
+ /**
113
+ * @param {number} id
114
+ */
115
+ export function dismiss(id) {
116
+ forget(id)
117
+
118
+ const remaining = toasts.filter((toast) => toast.id !== id)
119
+
120
+ if (remaining.length !== toasts.length) {
121
+ toasts = remaining
122
+ emitToasts()
123
+ }
124
+ }
125
+
126
+ /** @returns {Toast[]} */
127
+ export function getToasts() {
128
+ return toasts
129
+ }
130
+
131
+ /**
132
+ * @param {(toasts: Toast[]) => void} listener
133
+ * @returns {() => void} para dejar de escuchar
134
+ */
135
+ export function onToastsChange(listener) {
136
+ toastListeners.add(listener)
137
+
138
+ return () => toastListeners.delete(listener)
139
+ }
140
+
141
+ /**
142
+ * Vacía la cola. Sobre todo para las pruebas: sin esto un aviso de una prueba
143
+ * aparecería en la siguiente.
144
+ */
145
+ export function resetToasts() {
146
+ timers.forEach((timer) => clearTimeout(timer))
147
+ timers.clear()
148
+
149
+ toasts = []
150
+ nextToast = 1
151
+
152
+ emitToasts()
153
+ }
154
+
155
+ /**
156
+ * @param {number} id
157
+ */
158
+ function forget(id) {
159
+ clearTimeout(timers.get(id))
160
+ timers.delete(id)
161
+ }
162
+
163
+ // CONFIRMACIONES
164
+
165
+ /** @type {Confirmation|null} */
166
+ let confirmation = null
167
+
168
+ /** @type {((value: boolean) => void)|null} */
169
+ let resolver = null
170
+
171
+ let nextConfirmation = 1
172
+
173
+ /** @type {Set<(confirmation: Confirmation|null) => void>} */
174
+ const confirmationListeners = new Set()
175
+
176
+ const emitConfirmation = () => confirmationListeners.forEach((listener) => listener(confirmation))
177
+
178
+ /**
179
+ * Pregunta y espera la respuesta: `true` si se confirma, `false` si no.
180
+ *
181
+ * La pinta el ConfirmHostComponent que la aplicación monta una vez. Si no hay
182
+ * ninguno escuchando se usa `window.confirm`, que es feo pero contesta: una
183
+ * promesa que no se resolviera nunca dejaría colgada la acción que la espera.
184
+ *
185
+ * Una pregunta nueva con otra pendiente da la anterior por cancelada. Dos
186
+ * confirmaciones a la vez no tienen una respuesta clara.
187
+ *
188
+ * @param {string|{ message: string, title?: string, confirmLabel?: string, cancelLabel?: string, variant?: 'primary'|'danger' }} options
189
+ * @returns {Promise<boolean>}
190
+ */
191
+ export function confirmAction(options) {
192
+ const input = typeof options === 'string' ? { message: options } : (options ?? {})
193
+ const message = String(input.message ?? '')
194
+
195
+ if (confirmationListeners.size === 0) {
196
+ const native = typeof window !== 'undefined' ? window.confirm : undefined
197
+
198
+ return Promise.resolve(typeof native === 'function' ? Boolean(native.call(window, message)) : false)
199
+ }
200
+
201
+ if (resolver) {
202
+ settle(false)
203
+ }
204
+
205
+ return new Promise((resolve) => {
206
+ resolver = resolve
207
+
208
+ confirmation = {
209
+ id: nextConfirmation++,
210
+ title: input.title ?? '¿Confirmas?',
211
+ message,
212
+ confirmLabel: input.confirmLabel ?? 'Confirmar',
213
+ cancelLabel: input.cancelLabel ?? 'Cancelar',
214
+ variant: input.variant === 'danger' ? 'danger' : 'primary',
215
+ }
216
+
217
+ emitConfirmation()
218
+ })
219
+ }
220
+
221
+ /**
222
+ * La respuesta a la confirmación pendiente. La llama el componente que la pinta.
223
+ *
224
+ * @param {boolean} value
225
+ */
226
+ export function resolveConfirmation(value) {
227
+ if (resolver) {
228
+ settle(Boolean(value))
229
+ }
230
+ }
231
+
232
+ /** @returns {Confirmation|null} */
233
+ export function getConfirmation() {
234
+ return confirmation
235
+ }
236
+
237
+ /**
238
+ * @param {(confirmation: Confirmation|null) => void} listener
239
+ * @returns {() => void} para dejar de escuchar
240
+ */
241
+ export function onConfirmationChange(listener) {
242
+ confirmationListeners.add(listener)
243
+
244
+ return () => confirmationListeners.delete(listener)
245
+ }
246
+
247
+ /**
248
+ * Cancela lo pendiente. Sobre todo para las pruebas.
249
+ */
250
+ export function resetConfirmation() {
251
+ if (resolver) {
252
+ settle(false)
253
+
254
+ return
255
+ }
256
+
257
+ confirmation = null
258
+ emitConfirmation()
259
+ }
260
+
261
+ /**
262
+ * @param {boolean} value
263
+ */
264
+ function settle(value) {
265
+ const resolve = resolver
266
+
267
+ resolver = null
268
+ confirmation = null
269
+
270
+ emitConfirmation()
271
+
272
+ resolve?.(value)
273
+ }