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.
Files changed (37) hide show
  1. package/README.md +125 -0
  2. package/index.js +42 -0
  3. package/package.json +71 -0
  4. package/src/AvatarInputComponent.jsx +78 -0
  5. package/src/ButtonComponent.jsx +44 -0
  6. package/src/CheckboxInputComponent.jsx +55 -0
  7. package/src/ClickToEditComponent.jsx +65 -0
  8. package/src/CodeInputComponent.jsx +108 -0
  9. package/src/CodeMirrorComponent.jsx +62 -0
  10. package/src/ColorPickerInputComponent.jsx +85 -0
  11. package/src/CountrySelectInputComponent.jsx +89 -0
  12. package/src/DynamicGroupInputComponent.jsx +216 -0
  13. package/src/EditorInputComponent.jsx +52 -0
  14. package/src/FileDropInputComponent.jsx +51 -0
  15. package/src/FileInputComponent.jsx +186 -0
  16. package/src/FqsInputComponent.jsx +72 -0
  17. package/src/InputErrorComponent.jsx +26 -0
  18. package/src/ModelSearchInputComponent.jsx +107 -0
  19. package/src/MultiCheckboxInputComponent.jsx +35 -0
  20. package/src/PolymorphicInputComponent.jsx +181 -0
  21. package/src/RadioInputComponent.jsx +38 -0
  22. package/src/SelectInputComponent.jsx +51 -0
  23. package/src/SelectSearchInputComponent.jsx +102 -0
  24. package/src/SimpleFileInputComponent.jsx +40 -0
  25. package/src/SingleCheckboxInputComponent.jsx +33 -0
  26. package/src/StarsInputComponent.jsx +49 -0
  27. package/src/SwitchComponent.jsx +25 -0
  28. package/src/TagsInputComponent.jsx +88 -0
  29. package/src/TextEditorMonoStyleInputComponent.jsx +27 -0
  30. package/src/TextInputComponent.jsx +117 -0
  31. package/src/TextareaInputComponent.jsx +50 -0
  32. package/src/TimezoneSelectInputComponent.jsx +32 -0
  33. package/src/css/form-elements.css +187 -0
  34. package/src/internal/Field.jsx +21 -0
  35. package/src/internal/FieldLabel.jsx +29 -0
  36. package/src/internal/useControlled.js +38 -0
  37. 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)}>&times;</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
+ }