innoboxrr-react-form-elements 3.0.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 +125 -0
- package/index.js +42 -0
- package/package.json +71 -0
- package/src/AvatarInputComponent.jsx +78 -0
- package/src/ButtonComponent.jsx +44 -0
- package/src/CheckboxInputComponent.jsx +55 -0
- package/src/ClickToEditComponent.jsx +65 -0
- package/src/CodeInputComponent.jsx +108 -0
- package/src/CodeMirrorComponent.jsx +62 -0
- package/src/ColorPickerInputComponent.jsx +85 -0
- package/src/CountrySelectInputComponent.jsx +89 -0
- package/src/DynamicGroupInputComponent.jsx +216 -0
- package/src/EditorInputComponent.jsx +52 -0
- package/src/FileDropInputComponent.jsx +51 -0
- package/src/FileInputComponent.jsx +186 -0
- package/src/FqsInputComponent.jsx +72 -0
- package/src/InputErrorComponent.jsx +26 -0
- package/src/ModelSearchInputComponent.jsx +107 -0
- package/src/MultiCheckboxInputComponent.jsx +35 -0
- package/src/PolymorphicInputComponent.jsx +181 -0
- package/src/RadioInputComponent.jsx +38 -0
- package/src/SelectInputComponent.jsx +51 -0
- package/src/SelectSearchInputComponent.jsx +102 -0
- package/src/SimpleFileInputComponent.jsx +40 -0
- package/src/SingleCheckboxInputComponent.jsx +33 -0
- package/src/StarsInputComponent.jsx +49 -0
- package/src/SwitchComponent.jsx +25 -0
- package/src/TagsInputComponent.jsx +88 -0
- package/src/TextEditorMonoStyleInputComponent.jsx +27 -0
- package/src/TextInputComponent.jsx +117 -0
- package/src/TextareaInputComponent.jsx +50 -0
- package/src/TimezoneSelectInputComponent.jsx +32 -0
- package/src/css/form-elements.css +187 -0
- package/src/internal/Field.jsx +21 -0
- package/src/internal/FieldLabel.jsx +29 -0
- package/src/internal/useControlled.js +38 -0
- package/src/internal/useThemeClass.js +22 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useId } from 'react'
|
|
2
|
+
import { HexColorInput, HexColorPicker } from 'react-colorful'
|
|
3
|
+
import Field from './internal/Field.jsx'
|
|
4
|
+
import useControlled from './internal/useControlled.js'
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_COLORS = [
|
|
7
|
+
'#F44336', '#E91E63', '#9C27B0', '#673AB7',
|
|
8
|
+
'#3F51B5', '#2196F3', '#03A9F4', '#00BCD4',
|
|
9
|
+
'#009688', '#4CAF50', '#8BC34A', '#CDDC39',
|
|
10
|
+
'#FFEB3B', '#FFC107', '#FF9800', '#795548',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Gemelo de ColorPickerInputComponent.vue.
|
|
15
|
+
*
|
|
16
|
+
* La versión Vue intenta cargar lightvue y, si no está, cae a un
|
|
17
|
+
* `<input type="color">` — que abre el diálogo del sistema operativo y no se
|
|
18
|
+
* puede estilar ni probar. Aquí va react-colorful: 2,8 kB, sin dependencias,
|
|
19
|
+
* accesible y con el mismo aspecto en todos los navegadores.
|
|
20
|
+
*
|
|
21
|
+
* La paleta de accesos rápidos se mantiene porque es parte del contrato.
|
|
22
|
+
*/
|
|
23
|
+
export default function ColorPickerInputComponent({
|
|
24
|
+
id: providedId = undefined,
|
|
25
|
+
label = '',
|
|
26
|
+
help = null,
|
|
27
|
+
clearable = true,
|
|
28
|
+
colors = DEFAULT_COLORS,
|
|
29
|
+
name = 'color',
|
|
30
|
+
validators = null,
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
}) {
|
|
34
|
+
const generatedId = useId()
|
|
35
|
+
const uid = providedId ?? generatedId
|
|
36
|
+
const [current, set] = useControlled(value, onChange, '#607C8A')
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Field label={label} help={help} htmlFor={uid} inline={false}>
|
|
40
|
+
<div className="fe-flex" style={{ gap: '1rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
|
41
|
+
<HexColorPicker color={current || '#607C8A'} onChange={set} />
|
|
42
|
+
|
|
43
|
+
<div>
|
|
44
|
+
<HexColorInput
|
|
45
|
+
id={uid}
|
|
46
|
+
className="fe-input"
|
|
47
|
+
color={current || ''}
|
|
48
|
+
prefixed
|
|
49
|
+
onChange={set} />
|
|
50
|
+
|
|
51
|
+
<input type="hidden" name={name} data-validators={validators ?? undefined} value={current ?? ''} readOnly />
|
|
52
|
+
|
|
53
|
+
<div className="fe-flex fe-mt-sm" style={{ gap: '0.25rem', flexWrap: 'wrap', maxWidth: '12rem' }}>
|
|
54
|
+
{colors.map((color) => (
|
|
55
|
+
<button
|
|
56
|
+
key={color}
|
|
57
|
+
type="button"
|
|
58
|
+
aria-label={color}
|
|
59
|
+
title={color}
|
|
60
|
+
onClick={() => set(color)}
|
|
61
|
+
style={{
|
|
62
|
+
width: '1.25rem',
|
|
63
|
+
height: '1.25rem',
|
|
64
|
+
borderRadius: '0.25rem',
|
|
65
|
+
border: current === color ? '2px solid #111827' : '1px solid #d1d5db',
|
|
66
|
+
backgroundColor: color,
|
|
67
|
+
cursor: 'pointer',
|
|
68
|
+
padding: 0,
|
|
69
|
+
}} />
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{clearable ? (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
className="fe-button fe-button-link fe-mt-sm"
|
|
77
|
+
onClick={() => set('')}>
|
|
78
|
+
Limpiar
|
|
79
|
+
</button>
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</Field>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import PhoneInput, { isValidPhoneNumber, parsePhoneNumber } from 'react-phone-number-input'
|
|
3
|
+
import Field from './internal/Field.jsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gemelo de CountrySelectInputComponent.vue.
|
|
7
|
+
*
|
|
8
|
+
* La versión Vue envuelve vue-tel-input, que por debajo usa libphonenumber-js.
|
|
9
|
+
* `react-phone-number-input` es exactamente eso mismo en React: el mismo
|
|
10
|
+
* libphonenumber, la misma validación por país y el mismo selector con
|
|
11
|
+
* banderas. Antes esto era una lista de prefijos escrita a mano, que se queda
|
|
12
|
+
* desactualizada sola y valida cualquier cosa de 6 a 15 dígitos.
|
|
13
|
+
*
|
|
14
|
+
* `onCountryChange` recibe `{ phone, country, isValid }`, la misma forma que
|
|
15
|
+
* emitía `change` en Vue, para que el formulario que ya lo consumía siga
|
|
16
|
+
* funcionando igual.
|
|
17
|
+
*
|
|
18
|
+
* Los estilos van una vez por aplicación:
|
|
19
|
+
*
|
|
20
|
+
* import 'react-phone-number-input/style.css'
|
|
21
|
+
*/
|
|
22
|
+
export default function CountrySelectInputComponent({
|
|
23
|
+
wrapperClass = 'fe-mb',
|
|
24
|
+
containerClass = 'fe-inline fe-w-full',
|
|
25
|
+
labelClass = 'ml-2 text-sm font-medium text-gray-900 dark:text-white',
|
|
26
|
+
label = '',
|
|
27
|
+
help = null,
|
|
28
|
+
defaultPhone = '',
|
|
29
|
+
defaultCountry = null,
|
|
30
|
+
disabled = false,
|
|
31
|
+
name = 'telephone',
|
|
32
|
+
placeholder = 'Ingresa un número telefónico',
|
|
33
|
+
validators = null,
|
|
34
|
+
onCountryChange,
|
|
35
|
+
...rest
|
|
36
|
+
}) {
|
|
37
|
+
const [phone, setPhone] = useState(defaultPhone ? String(defaultPhone) : '')
|
|
38
|
+
const [country, setCountry] = useState(defaultCountry ?? undefined)
|
|
39
|
+
|
|
40
|
+
const valid = Boolean(phone) && isValidPhoneNumber(phone)
|
|
41
|
+
|
|
42
|
+
const announce = (nextPhone, nextCountry) => {
|
|
43
|
+
const isValid = Boolean(nextPhone) && isValidPhoneNumber(nextPhone)
|
|
44
|
+
const parsed = isValid ? parsePhoneNumber(nextPhone) : null
|
|
45
|
+
|
|
46
|
+
onCountryChange?.({
|
|
47
|
+
phone: isValid ? nextPhone : '',
|
|
48
|
+
country: parsed?.country ?? nextCountry ?? null,
|
|
49
|
+
callingCode: parsed?.countryCallingCode ?? null,
|
|
50
|
+
national: parsed?.nationalNumber ?? null,
|
|
51
|
+
isValid,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={wrapperClass}>
|
|
57
|
+
<div className={containerClass}>
|
|
58
|
+
{label ? <label className={labelClass} htmlFor={name}>{label}</label> : null}
|
|
59
|
+
|
|
60
|
+
<PhoneInput
|
|
61
|
+
id={name}
|
|
62
|
+
name={name}
|
|
63
|
+
className={(! valid && phone.length !== 0) ? 'error' : undefined}
|
|
64
|
+
international
|
|
65
|
+
countryCallingCodeEditable={false}
|
|
66
|
+
defaultCountry={defaultCountry ?? undefined}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
placeholder={placeholder}
|
|
69
|
+
data-validators={validators ?? undefined}
|
|
70
|
+
value={phone}
|
|
71
|
+
onCountryChange={(next) => {
|
|
72
|
+
setCountry(next)
|
|
73
|
+
announce(phone, next)
|
|
74
|
+
}}
|
|
75
|
+
onChange={(next) => {
|
|
76
|
+
const clean = next ?? ''
|
|
77
|
+
|
|
78
|
+
setPhone(clean)
|
|
79
|
+
announce(clean, country)
|
|
80
|
+
}}
|
|
81
|
+
{...rest} />
|
|
82
|
+
|
|
83
|
+
{help ? <p className="text-sm text-gray-500">{help}</p> : null}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { isValidPhoneNumber, parsePhoneNumber }
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
DndContext,
|
|
4
|
+
KeyboardSensor,
|
|
5
|
+
PointerSensor,
|
|
6
|
+
closestCenter,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
} from '@dnd-kit/core'
|
|
10
|
+
import {
|
|
11
|
+
SortableContext,
|
|
12
|
+
sortableKeyboardCoordinates,
|
|
13
|
+
useSortable,
|
|
14
|
+
verticalListSortingStrategy,
|
|
15
|
+
} from '@dnd-kit/sortable'
|
|
16
|
+
import { CSS } from '@dnd-kit/utilities'
|
|
17
|
+
|
|
18
|
+
import EditorInputComponent from './EditorInputComponent.jsx'
|
|
19
|
+
import SelectInputComponent from './SelectInputComponent.jsx'
|
|
20
|
+
import TextInputComponent from './TextInputComponent.jsx'
|
|
21
|
+
import TextareaInputComponent from './TextareaInputComponent.jsx'
|
|
22
|
+
import useControlled from './internal/useControlled.js'
|
|
23
|
+
|
|
24
|
+
const COMPONENTS = {
|
|
25
|
+
text: TextInputComponent,
|
|
26
|
+
editor: EditorInputComponent,
|
|
27
|
+
select: SelectInputComponent,
|
|
28
|
+
textarea: TextareaInputComponent,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Gemelo de DynamicGroupInputComponent.vue: N grupos de campos, con orden,
|
|
33
|
+
* duplicado y borrado.
|
|
34
|
+
*
|
|
35
|
+
* La versión Vue usa vuedraggable (SortableJS). En React el equivalente vivo
|
|
36
|
+
* es dnd-kit: es el sucesor de react-beautiful-dnd —archivado—, no toca el DOM
|
|
37
|
+
* por su cuenta y trae ordenación **por teclado**, que ni SortableJS ni la API
|
|
38
|
+
* nativa de arrastre dan. Aquí no es un detalle: un formulario que solo se
|
|
39
|
+
* puede reordenar con el ratón no es accesible.
|
|
40
|
+
*/
|
|
41
|
+
function SortableGroup({ id, children }) {
|
|
42
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
ref={setNodeRef}
|
|
47
|
+
style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }}
|
|
48
|
+
className="border rounded-lg bg-white dark:bg-slate-800 shadow-sm relative dark:border-slate-600">
|
|
49
|
+
{children({ attributes, listeners })}
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default function DynamicGroupInputComponent({
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
inputsConfig,
|
|
58
|
+
label = '',
|
|
59
|
+
addButtonLabel = 'Añadir',
|
|
60
|
+
removeButtonLabel = 'Eliminar',
|
|
61
|
+
itemLabel = 'Item',
|
|
62
|
+
}) {
|
|
63
|
+
const [current, set] = useControlled(value, onChange, [])
|
|
64
|
+
const nextKey = useRef(0)
|
|
65
|
+
|
|
66
|
+
const groups = current ?? []
|
|
67
|
+
|
|
68
|
+
const sensors = useSensors(
|
|
69
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
70
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// dnd-kit necesita un id estable por elemento. El indice no vale: al
|
|
74
|
+
// reordenar cambia, y el elemento arrastrado saltaria.
|
|
75
|
+
const keyed = useMemo(
|
|
76
|
+
() => groups.map((group) => {
|
|
77
|
+
if (! group.__key) {
|
|
78
|
+
group.__key = `grupo-${nextKey.current++}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return group
|
|
82
|
+
}),
|
|
83
|
+
[groups]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const emptyGroup = () => inputsConfig.reduce(
|
|
87
|
+
(group, field) => ({ ...group, [field.key]: '' }),
|
|
88
|
+
{ _collapsed: false, __key: `grupo-${nextKey.current++}` }
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const updateAt = (index, key, next) => set(
|
|
92
|
+
keyed.map((group, position) => (position === index ? { ...group, [key]: next } : group))
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const onDragEnd = useCallback((event) => {
|
|
96
|
+
const { active, over } = event
|
|
97
|
+
|
|
98
|
+
if (! over || active.id === over.id) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const from = keyed.findIndex((group) => group.__key === active.id)
|
|
103
|
+
const to = keyed.findIndex((group) => group.__key === over.id)
|
|
104
|
+
|
|
105
|
+
const next = [...keyed]
|
|
106
|
+
const [moved] = next.splice(from, 1)
|
|
107
|
+
|
|
108
|
+
next.splice(to, 0, moved)
|
|
109
|
+
set(next)
|
|
110
|
+
}, [keyed, set])
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div>
|
|
114
|
+
{label ? (
|
|
115
|
+
<label className="block mb-4 ml-2 text-sm font-medium text-slate-900 dark:text-slate-100">
|
|
116
|
+
{label}
|
|
117
|
+
</label>
|
|
118
|
+
) : null}
|
|
119
|
+
|
|
120
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
121
|
+
<SortableContext items={keyed.map((group) => group.__key)} strategy={verticalListSortingStrategy}>
|
|
122
|
+
<div className="space-y-2 rounded-lg">
|
|
123
|
+
{keyed.map((group, index) => (
|
|
124
|
+
<SortableGroup key={group.__key} id={group.__key}>
|
|
125
|
+
{({ attributes, listeners }) => (
|
|
126
|
+
<>
|
|
127
|
+
<div className="flex justify-between items-center px-4 py-3 border-b bg-slate-50 dark:bg-slate-700 dark:border-slate-600 rounded-t-lg">
|
|
128
|
+
<div className="flex items-center gap-2">
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
aria-label={`Mover ${itemLabel} ${index + 1}`}
|
|
132
|
+
className="cursor-move drag-handle text-slate-400"
|
|
133
|
+
{...attributes}
|
|
134
|
+
{...listeners}>
|
|
135
|
+
<i className="fa-solid fa-grip-vertical"></i>
|
|
136
|
+
</button>
|
|
137
|
+
<h4 className="text-md font-semibold text-slate-800 dark:text-slate-100">
|
|
138
|
+
{itemLabel} #{index + 1}
|
|
139
|
+
</h4>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div className="flex items-center space-x-4 text-slate-400">
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
aria-label={`Duplicar ${itemLabel} ${index + 1}`}
|
|
146
|
+
className="hover:text-blue-500 transition mr-2"
|
|
147
|
+
onClick={() => set([
|
|
148
|
+
...keyed.slice(0, index + 1),
|
|
149
|
+
{ ...group, __key: `grupo-${nextKey.current++}` },
|
|
150
|
+
...keyed.slice(index + 1),
|
|
151
|
+
])}>
|
|
152
|
+
<i className="fa-solid fa-clone"></i>
|
|
153
|
+
</button>
|
|
154
|
+
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
aria-label={`${removeButtonLabel} ${index + 1}`}
|
|
158
|
+
className="text-red-800 dark:text-red-400 text-sm"
|
|
159
|
+
onClick={() => set(keyed.filter((_, position) => position !== index))}>
|
|
160
|
+
<i className="fa-solid fa-trash"></i>
|
|
161
|
+
</button>
|
|
162
|
+
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
aria-label={`Expandir ${itemLabel} ${index + 1}`}
|
|
166
|
+
aria-expanded={! group._collapsed}
|
|
167
|
+
className="hover:text-slate-600 dark:hover:text-slate-300 transition"
|
|
168
|
+
onClick={() => updateAt(index, '_collapsed', ! group._collapsed)}>
|
|
169
|
+
<i className={`fa-solid ${group._collapsed ? 'fa-chevron-up' : 'fa-chevron-down'}`}></i>
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{! group._collapsed ? (
|
|
175
|
+
<div className="p-4">
|
|
176
|
+
{inputsConfig.map((field) => {
|
|
177
|
+
const Component = COMPONENTS[field.type] ?? TextInputComponent
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<Component
|
|
181
|
+
key={field.key}
|
|
182
|
+
type={field.type === 'text' ? 'text' : undefined}
|
|
183
|
+
id={`${field.key}-${index}`}
|
|
184
|
+
name={`${field.key}[${index}]`}
|
|
185
|
+
label={field.label ?? field.key}
|
|
186
|
+
value={group[field.key] ?? ''}
|
|
187
|
+
onChange={(next) => updateAt(index, field.key, next)}>
|
|
188
|
+
{field.type === 'select' && field.options
|
|
189
|
+
? field.options.map((option) => (
|
|
190
|
+
<option key={option.value ?? option} value={option.value ?? option}>
|
|
191
|
+
{option.label ?? option}
|
|
192
|
+
</option>
|
|
193
|
+
))
|
|
194
|
+
: null}
|
|
195
|
+
</Component>
|
|
196
|
+
)
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
) : null}
|
|
200
|
+
</>
|
|
201
|
+
)}
|
|
202
|
+
</SortableGroup>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
</SortableContext>
|
|
206
|
+
</DndContext>
|
|
207
|
+
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
className="fe-button fe-mt"
|
|
211
|
+
onClick={() => set([...keyed, emptyGroup()])}>
|
|
212
|
+
{addButtonLabel}
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Editor } from '@tinymce/tinymce-react'
|
|
2
|
+
import Field from './internal/Field.jsx'
|
|
3
|
+
import useControlled from './internal/useControlled.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gemelo de EditorInputComponent.vue.
|
|
7
|
+
*
|
|
8
|
+
* `@tinymce/tinymce-react` es el envoltorio oficial, gemelo exacto del
|
|
9
|
+
* `@tinymce/tinymce-vue` que declara la versión Vue. Antes se cargaba con un
|
|
10
|
+
* import diferido y, si no estaba, quedaba un `<textarea>`: como era opcional,
|
|
11
|
+
* lo normal era acabar con el textarea.
|
|
12
|
+
*
|
|
13
|
+
* `apiKey` viene de TinyMCE Cloud. Para una instalación propia se pasa
|
|
14
|
+
* `tinymceScriptSrc` apuntando al `tinymce.min.js` servido por la aplicación.
|
|
15
|
+
*/
|
|
16
|
+
export default function EditorInputComponent({
|
|
17
|
+
id,
|
|
18
|
+
name,
|
|
19
|
+
label = '',
|
|
20
|
+
help = null,
|
|
21
|
+
height = 300,
|
|
22
|
+
disabled = false,
|
|
23
|
+
initialValue = '',
|
|
24
|
+
validators = null,
|
|
25
|
+
apiKey = undefined,
|
|
26
|
+
tinymceScriptSrc = undefined,
|
|
27
|
+
plugins = 'lists link image table code help wordcount',
|
|
28
|
+
toolbar = 'undo redo | blocks | bold italic | bullist numlist | link image | code',
|
|
29
|
+
value,
|
|
30
|
+
onChange,
|
|
31
|
+
...rest
|
|
32
|
+
}) {
|
|
33
|
+
const [current, set] = useControlled(value, onChange, initialValue)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Field label={label} help={help} htmlFor={id} inline={false}>
|
|
37
|
+
<Editor
|
|
38
|
+
id={id}
|
|
39
|
+
apiKey={apiKey}
|
|
40
|
+
tinymceScriptSrc={tinymceScriptSrc}
|
|
41
|
+
disabled={disabled}
|
|
42
|
+
value={current ?? ''}
|
|
43
|
+
init={{ height, menubar: false, plugins, toolbar }}
|
|
44
|
+
onEditorChange={set}
|
|
45
|
+
{...rest} />
|
|
46
|
+
|
|
47
|
+
{/* El validador del proyecto lee data-validators del DOM, y el
|
|
48
|
+
editor no expone un input donde ponerlo. */}
|
|
49
|
+
<input type="hidden" name={name} data-validators={validators ?? undefined} value={current ?? ''} readOnly />
|
|
50
|
+
</Field>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemelo de FileDropInputComponent.vue. `onFilesChange` recibe un array de
|
|
5
|
+
* File, venga de arrastrar o del diálogo.
|
|
6
|
+
*/
|
|
7
|
+
export default function FileDropInputComponent({
|
|
8
|
+
multiple = false,
|
|
9
|
+
accept = undefined,
|
|
10
|
+
mainText = 'Arrastra y suelta el archivo aquí',
|
|
11
|
+
subText = 'o haz clic para seleccionar los archivos.',
|
|
12
|
+
onFilesChange,
|
|
13
|
+
}) {
|
|
14
|
+
const input = useRef(null)
|
|
15
|
+
const [dragging, setDragging] = useState(false)
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
className="fe-file-drop"
|
|
20
|
+
data-dragging={dragging}
|
|
21
|
+
role="button"
|
|
22
|
+
tabIndex={0}
|
|
23
|
+
onClick={() => input.current?.click()}
|
|
24
|
+
onKeyDown={(event) => {
|
|
25
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
26
|
+
input.current?.click()
|
|
27
|
+
}
|
|
28
|
+
}}
|
|
29
|
+
onDragOver={(event) => {
|
|
30
|
+
event.preventDefault()
|
|
31
|
+
setDragging(true)
|
|
32
|
+
}}
|
|
33
|
+
onDragLeave={() => setDragging(false)}
|
|
34
|
+
onDrop={(event) => {
|
|
35
|
+
event.preventDefault()
|
|
36
|
+
setDragging(false)
|
|
37
|
+
onFilesChange?.(Array.from(event.dataTransfer.files))
|
|
38
|
+
}}>
|
|
39
|
+
<p>{mainText}</p>
|
|
40
|
+
<p>{subText}</p>
|
|
41
|
+
|
|
42
|
+
<input
|
|
43
|
+
ref={input}
|
|
44
|
+
type="file"
|
|
45
|
+
multiple={multiple}
|
|
46
|
+
accept={accept}
|
|
47
|
+
style={{ display: 'none' }}
|
|
48
|
+
onChange={(event) => onFilesChange?.(Array.from(event.target.files ?? []))} />
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { describeFiles, sizeParser } from 'innoboxrr-form-core'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MIMES = [
|
|
5
|
+
'text/plain',
|
|
6
|
+
'image/gif', 'image/jpeg', 'image/png',
|
|
7
|
+
'audio/mp3', 'audio/mpeg', 'audio/midi',
|
|
8
|
+
'video/mp4', 'video/quicktime',
|
|
9
|
+
'application/pdf',
|
|
10
|
+
'application/msword',
|
|
11
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
12
|
+
'application/vnd.ms-excel',
|
|
13
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
14
|
+
'application/zip',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Gemelo de FileInputComponent.vue: soltar o elegir archivos, validarlos y
|
|
19
|
+
* subirlos a `uploadUrl`.
|
|
20
|
+
*
|
|
21
|
+
* Se conservan los tres avisos del original — `onStartUpload`,
|
|
22
|
+
* `onFileListChange`, `onEndUpload` —, con nombres de prop de React.
|
|
23
|
+
*/
|
|
24
|
+
export default function FileInputComponent({
|
|
25
|
+
uploadUrl,
|
|
26
|
+
method = 'POST',
|
|
27
|
+
autoUpload = false,
|
|
28
|
+
name = 'file',
|
|
29
|
+
visibility = 'public',
|
|
30
|
+
maxSize = 0,
|
|
31
|
+
totalMaxSize = 0,
|
|
32
|
+
maxFiles = 1,
|
|
33
|
+
validMimes = DEFAULT_MIMES,
|
|
34
|
+
onStartUpload,
|
|
35
|
+
onFileListChange,
|
|
36
|
+
onEndUpload,
|
|
37
|
+
}) {
|
|
38
|
+
const input = useRef(null)
|
|
39
|
+
const [files, setFiles] = useState([])
|
|
40
|
+
const [errors, setErrors] = useState([])
|
|
41
|
+
const [dragging, setDragging] = useState(false)
|
|
42
|
+
const [uploading, setUploading] = useState(false)
|
|
43
|
+
|
|
44
|
+
const rules = useMemo(() => ({ maxSize, validMimes }), [maxSize, validMimes])
|
|
45
|
+
|
|
46
|
+
const multiple = maxFiles > 1
|
|
47
|
+
const maxFilesReached = files.length >= maxFiles
|
|
48
|
+
|
|
49
|
+
const csrfToken = () => globalThis.csrf_token
|
|
50
|
+
?? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
|
51
|
+
?? ''
|
|
52
|
+
|
|
53
|
+
const push = useCallback(async (incoming) => {
|
|
54
|
+
if (uploading) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const described = describeFiles(incoming, rules)
|
|
59
|
+
|
|
60
|
+
setFiles((current) => [...current, ...described.filter((entry) => entry.validation)])
|
|
61
|
+
setErrors((current) => [...current, ...described.filter((entry) => ! entry.validation)])
|
|
62
|
+
}, [rules, uploading])
|
|
63
|
+
|
|
64
|
+
const upload = async () => {
|
|
65
|
+
setUploading(true)
|
|
66
|
+
onStartUpload?.(true)
|
|
67
|
+
|
|
68
|
+
const uploaded = []
|
|
69
|
+
|
|
70
|
+
for (const entry of files) {
|
|
71
|
+
if (entry.uploaded || ! entry.validation) {
|
|
72
|
+
uploaded.push(entry)
|
|
73
|
+
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const body = new FormData()
|
|
78
|
+
|
|
79
|
+
body.append('_token', csrfToken())
|
|
80
|
+
// El File de verdad, no el descriptor.
|
|
81
|
+
body.append('file', entry.file)
|
|
82
|
+
body.append('visibility', visibility)
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(uploadUrl, { method, body })
|
|
86
|
+
|
|
87
|
+
if (! response.ok) {
|
|
88
|
+
throw new Error('An error has occurred')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const data = await response.json()
|
|
92
|
+
|
|
93
|
+
// El original mutaba el File en sitio. Aqui se copian los
|
|
94
|
+
// datos de la respuesta en una entrada nueva: mutar un File
|
|
95
|
+
// que React tiene en estado no dispara ningun re-render.
|
|
96
|
+
uploaded.push({ ...entry, uploaded: true, path: data.path, id: data.id, response: data })
|
|
97
|
+
} catch (error) {
|
|
98
|
+
uploaded.push(entry)
|
|
99
|
+
|
|
100
|
+
setErrors((current) => [...current, { name: entry.name, errors: [String(error)] }])
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setFiles(uploaded)
|
|
105
|
+
setUploading(false)
|
|
106
|
+
|
|
107
|
+
onFileListChange?.(uploaded)
|
|
108
|
+
onEndUpload?.(true)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const remove = (target) => {
|
|
112
|
+
// El original hacia `files.pop(file)`: pop() ignora sus argumentos y
|
|
113
|
+
// quita el ultimo lote, asi que borrar un archivo eliminaba otro.
|
|
114
|
+
setFiles((current) => current.filter((entry) => entry !== target))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const totalSize = files.reduce((sum, entry) => sum + entry.size, 0)
|
|
118
|
+
const overTotal = totalMaxSize > 0 && totalSize > totalMaxSize
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div>
|
|
122
|
+
<div
|
|
123
|
+
className="fe-file-drop"
|
|
124
|
+
data-dragging={dragging}
|
|
125
|
+
role="button"
|
|
126
|
+
tabIndex={0}
|
|
127
|
+
onClick={() => (maxFilesReached ? null : input.current?.click())}
|
|
128
|
+
onKeyDown={(event) => {
|
|
129
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
130
|
+
input.current?.click()
|
|
131
|
+
}
|
|
132
|
+
}}
|
|
133
|
+
onDragOver={(event) => {
|
|
134
|
+
event.preventDefault()
|
|
135
|
+
setDragging(true)
|
|
136
|
+
}}
|
|
137
|
+
onDragLeave={() => setDragging(false)}
|
|
138
|
+
onDrop={(event) => {
|
|
139
|
+
event.preventDefault()
|
|
140
|
+
setDragging(false)
|
|
141
|
+
push(Array.from(event.dataTransfer.files))
|
|
142
|
+
}}>
|
|
143
|
+
<p>{maxFilesReached ? 'Máximo de archivos alcanzado' : 'Arrastra y suelta o haz clic'}</p>
|
|
144
|
+
|
|
145
|
+
<input
|
|
146
|
+
ref={input}
|
|
147
|
+
type="file"
|
|
148
|
+
name={name}
|
|
149
|
+
multiple={multiple}
|
|
150
|
+
style={{ display: 'none' }}
|
|
151
|
+
onChange={(event) => {
|
|
152
|
+
push(Array.from(event.target.files ?? []))
|
|
153
|
+
event.target.value = ''
|
|
154
|
+
}} />
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<ul className="fe-list">
|
|
158
|
+
{files.map((entry, index) => (
|
|
159
|
+
<li key={`${entry.name}-${index}`}>
|
|
160
|
+
<img src={entry.preview} alt={entry.name} width="48" />
|
|
161
|
+
<span>{entry.name}</span>
|
|
162
|
+
<span> ({sizeParser(entry.size)})</span>
|
|
163
|
+
{entry.uploaded ? <span> ✓</span> : null}
|
|
164
|
+
<button type="button" onClick={() => remove(entry)}>×</button>
|
|
165
|
+
</li>
|
|
166
|
+
))}
|
|
167
|
+
</ul>
|
|
168
|
+
|
|
169
|
+
{errors.length ? (
|
|
170
|
+
<ul className="fe-list text-red-600">
|
|
171
|
+
{errors.map((entry, index) => (
|
|
172
|
+
<li key={`${entry.name}-error-${index}`}>{entry.name}: {entry.errors?.join(', ')}</li>
|
|
173
|
+
))}
|
|
174
|
+
</ul>
|
|
175
|
+
) : null}
|
|
176
|
+
|
|
177
|
+
{overTotal ? <p className="text-red-600">El tamaño total supera el máximo permitido.</p> : null}
|
|
178
|
+
|
|
179
|
+
{! autoUpload && files.length ? (
|
|
180
|
+
<button type="button" className="fe-button" disabled={uploading || overTotal} onClick={upload}>
|
|
181
|
+
{uploading ? 'Subiendo…' : 'Subir'}
|
|
182
|
+
</button>
|
|
183
|
+
) : null}
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|