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 +32 -0
- package/index.js +20 -4
- package/package.json +2 -1
- package/src/components.css +19 -0
- package/src/feedback.js +273 -0
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
|
|
5
|
-
* no depende de ningun framework: el tema,
|
|
6
|
-
*
|
|
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
|
|
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.
|
|
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",
|
package/src/components.css
CHANGED
|
@@ -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;
|
package/src/feedback.js
ADDED
|
@@ -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
|
+
}
|