innoboxrr-form-core 2.4.0 → 2.5.1

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.1",
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",
@@ -172,6 +172,10 @@
172
172
  .fe-select,
173
173
  .fe-textarea,
174
174
  .fe-file {
175
+ /* Con width: 100% y padding, sin border-box el control mide más que su
176
+ contenedor. Una aplicación que ya pone border-box a todo no lo nota; una
177
+ que no, ve una barra de desplazamiento en cada drawer. */
178
+ box-sizing: border-box;
175
179
  width: 100%;
176
180
  min-height: var(--fe-control-height);
177
181
  padding: 0 var(--fe-control-padding-x);
@@ -271,6 +275,7 @@
271
275
  .fe-button-secondary,
272
276
  .fe-button-danger,
273
277
  .fe-button-link {
278
+ box-sizing: border-box;
274
279
  display: inline-flex;
275
280
  align-items: center;
276
281
  justify-content: center;
@@ -332,6 +337,7 @@
332
337
  /* Un botón que solo lleva un icono: cerrar un diálogo, abrir el menú de una
333
338
  fila. Cuadrado, para que el objetivo no dependa del ancho del glifo. */
334
339
  .fe-icon-button {
340
+ box-sizing: border-box;
335
341
  display: inline-flex;
336
342
  align-items: center;
337
343
  justify-content: center;
@@ -625,6 +631,7 @@
625
631
  }
626
632
 
627
633
  .fe-dialog {
634
+ box-sizing: border-box;
628
635
  width: min(var(--fe-dialog-width), calc(100vw - 2 * var(--fe-space-4)));
629
636
  max-height: 85vh;
630
637
  padding: 0;
@@ -694,6 +701,7 @@
694
701
  /* Un drawer es un <dialog> pegado a un borde. El formulario de crear o
695
702
  editar se abre aquí y la tabla sigue a la vista, detrás. */
696
703
  .fe-drawer {
704
+ box-sizing: border-box;
697
705
  width: min(var(--fe-drawer-width), 100vw);
698
706
  max-width: 100vw;
699
707
  height: 100dvh;
@@ -803,6 +811,7 @@
803
811
  }
804
812
 
805
813
  .fe-menu-item {
814
+ box-sizing: border-box;
806
815
  display: flex;
807
816
  align-items: center;
808
817
  gap: var(--fe-space-2);
@@ -892,6 +901,7 @@
892
901
  }
893
902
 
894
903
  .fe-command-input {
904
+ box-sizing: border-box;
895
905
  width: 100%;
896
906
  min-height: calc(var(--fe-control-height) * 1.4);
897
907
  padding: 0 var(--fe-space-5);
@@ -928,6 +938,7 @@
928
938
  }
929
939
 
930
940
  .fe-command-item {
941
+ box-sizing: border-box;
931
942
  display: flex;
932
943
  align-items: center;
933
944
  gap: var(--fe-space-3);
@@ -1168,6 +1179,14 @@
1168
1179
  min-height: 1.5em;
1169
1180
  padding: 0 var(--fe-space-1);
1170
1181
  margin: 0 calc(-1 * var(--fe-space-1));
1182
+ /* Es un <button> para que se alcance con teclado, pero tiene que leerse
1183
+ como el texto que muestra: sin el fondo, el borde ni la fuente que el
1184
+ navegador le pone a un botón. */
1185
+ font: inherit;
1186
+ color: inherit;
1187
+ text-align: left;
1188
+ background: none;
1189
+ border: none;
1171
1190
  border-radius: var(--fe-radius-sm);
1172
1191
  border-bottom: 1px dashed transparent;
1173
1192
  cursor: text;
@@ -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
+ }