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,72 @@
|
|
|
1
|
+
import TextInputComponent from './TextInputComponent.jsx'
|
|
2
|
+
import TextareaInputComponent from './TextareaInputComponent.jsx'
|
|
3
|
+
import useControlled from './internal/useControlled.js'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LABELS = {
|
|
6
|
+
title: 'Add frequency asked questions',
|
|
7
|
+
question: 'Question',
|
|
8
|
+
answer: 'Answer',
|
|
9
|
+
add: 'Add Question',
|
|
10
|
+
remove: 'Remove question',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Gemelo de FqsInputComponent.vue: la lista de preguntas frecuentes.
|
|
15
|
+
*
|
|
16
|
+
* El valor es un array de `{ question, answer }` y nunca se muta en sitio: se
|
|
17
|
+
* emite uno nuevo, como ya hace la versión Vue corregida.
|
|
18
|
+
*/
|
|
19
|
+
export default function FqsInputComponent({
|
|
20
|
+
value,
|
|
21
|
+
onChange,
|
|
22
|
+
inputClass = 'fe-input ',
|
|
23
|
+
labels = DEFAULT_LABELS,
|
|
24
|
+
name = 'fqs',
|
|
25
|
+
}) {
|
|
26
|
+
const [current, set] = useControlled(value, onChange, [])
|
|
27
|
+
|
|
28
|
+
const items = current ?? []
|
|
29
|
+
const text = { ...DEFAULT_LABELS, ...labels }
|
|
30
|
+
|
|
31
|
+
const updateAt = (index, field, next) => set(
|
|
32
|
+
items.map((item, position) => (position === index ? { ...item, [field]: next } : item))
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div>
|
|
37
|
+
<h4>{text.title}</h4>
|
|
38
|
+
|
|
39
|
+
{items.map((item, index) => (
|
|
40
|
+
<div key={index} className="fe-card fe-card-sm fe-card-body fe-mb">
|
|
41
|
+
<TextInputComponent
|
|
42
|
+
type="text"
|
|
43
|
+
name={`${name}[${index}][question]`}
|
|
44
|
+
label={text.question}
|
|
45
|
+
customClass={inputClass}
|
|
46
|
+
value={item.question ?? ''}
|
|
47
|
+
onChange={(next) => updateAt(index, 'question', next)} />
|
|
48
|
+
|
|
49
|
+
<TextareaInputComponent
|
|
50
|
+
name={`${name}[${index}][answer]`}
|
|
51
|
+
label={text.answer}
|
|
52
|
+
value={item.answer ?? ''}
|
|
53
|
+
onChange={(next) => updateAt(index, 'answer', next)} />
|
|
54
|
+
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
className="fe-button fe-button-danger fe-button-sm"
|
|
58
|
+
onClick={() => set(items.filter((_, position) => position !== index))}>
|
|
59
|
+
{text.remove}
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
))}
|
|
63
|
+
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
className="fe-button"
|
|
67
|
+
onClick={() => set([...items, { question: '', answer: '' }])}>
|
|
68
|
+
{text.add}
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemelo de InputErrorComponent.vue.
|
|
3
|
+
*
|
|
4
|
+
* `errors` es el objeto que devuelve Laravel en un 422 (`{ campo: [...] }`) y
|
|
5
|
+
* `type` la clave que mira este control.
|
|
6
|
+
*/
|
|
7
|
+
import { useSyncExternalStore } from 'react'
|
|
8
|
+
import { getTheme, onThemeChange } from 'innoboxrr-form-core'
|
|
9
|
+
|
|
10
|
+
export default function InputErrorComponent({ errors, type }) {
|
|
11
|
+
const theme = useSyncExternalStore(onThemeChange, () => getTheme(), () => getTheme())
|
|
12
|
+
|
|
13
|
+
const messages = errors?.[type]
|
|
14
|
+
|
|
15
|
+
if (! messages?.length) {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
{messages.map((error) => (
|
|
22
|
+
<p key={error} className={theme.error}>{error}</p>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import SelectSearchInputComponent from './SelectSearchInputComponent.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Gemelo de ModelSearchInputComponent.vue: busca contra un endpoint y deja
|
|
6
|
+
* elegir un registro.
|
|
7
|
+
*
|
|
8
|
+
* `onSubmit` recibe el id elegido y `onSelected` el registro completo, igual
|
|
9
|
+
* que los eventos `submit` y `selected` de la versión Vue.
|
|
10
|
+
*/
|
|
11
|
+
export default function ModelSearchInputComponent({
|
|
12
|
+
customClass = null,
|
|
13
|
+
hideOnEmit = false,
|
|
14
|
+
labelStr,
|
|
15
|
+
placeholderStr,
|
|
16
|
+
route,
|
|
17
|
+
method = 'get',
|
|
18
|
+
q = 'id',
|
|
19
|
+
externalFilters = {},
|
|
20
|
+
reduce = (option) => option.id,
|
|
21
|
+
optionLabel = 'name',
|
|
22
|
+
multiple = false,
|
|
23
|
+
minLength = 1,
|
|
24
|
+
debounce = 300,
|
|
25
|
+
value,
|
|
26
|
+
onSubmit,
|
|
27
|
+
onSelected,
|
|
28
|
+
}) {
|
|
29
|
+
const [options, setOptions] = useState([])
|
|
30
|
+
const [selected, setSelected] = useState(value ?? null)
|
|
31
|
+
const [visible, setVisible] = useState(true)
|
|
32
|
+
const timer = useRef(null)
|
|
33
|
+
|
|
34
|
+
const search = useCallback(async (term) => {
|
|
35
|
+
if (term.length < minLength) {
|
|
36
|
+
setOptions([])
|
|
37
|
+
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const params = { ...externalFilters, [q]: term }
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (method.toLowerCase() === 'get') {
|
|
45
|
+
const url = new URL(route, globalThis.location?.origin ?? 'http://localhost')
|
|
46
|
+
|
|
47
|
+
Object.entries(params).forEach(([key, item]) => url.searchParams.set(key, item))
|
|
48
|
+
|
|
49
|
+
const response = await fetch(url, { headers: { Accept: 'application/json' } })
|
|
50
|
+
|
|
51
|
+
setOptions((await response.json())?.data ?? [])
|
|
52
|
+
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const response = await fetch(route, {
|
|
57
|
+
method: method.toUpperCase(),
|
|
58
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
59
|
+
body: JSON.stringify(params),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
setOptions((await response.json())?.data ?? [])
|
|
63
|
+
} catch {
|
|
64
|
+
setOptions([])
|
|
65
|
+
}
|
|
66
|
+
}, [route, method, q, externalFilters, minLength])
|
|
67
|
+
|
|
68
|
+
// Un fetch por tecla satura el endpoint; la version Vue lo dejaba al
|
|
69
|
+
// criterio de quien montara el componente.
|
|
70
|
+
useEffect(() => () => window.clearTimeout(timer.current), [])
|
|
71
|
+
|
|
72
|
+
if (! visible) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<SelectSearchInputComponent
|
|
78
|
+
customClass={customClass ?? undefined}
|
|
79
|
+
name={q}
|
|
80
|
+
inputLabel={labelStr}
|
|
81
|
+
placeholder={placeholderStr}
|
|
82
|
+
options={options}
|
|
83
|
+
label={optionLabel}
|
|
84
|
+
reduce={reduce}
|
|
85
|
+
value={selected}
|
|
86
|
+
onSearch={(term) => {
|
|
87
|
+
window.clearTimeout(timer.current)
|
|
88
|
+
timer.current = window.setTimeout(() => search(term), debounce)
|
|
89
|
+
}}
|
|
90
|
+
onChange={(next) => {
|
|
91
|
+
setSelected(next)
|
|
92
|
+
onSubmit?.(next)
|
|
93
|
+
|
|
94
|
+
if (! multiple && next != null) {
|
|
95
|
+
const record = options.find((option) => reduce(option) === next)
|
|
96
|
+
|
|
97
|
+
if (record) {
|
|
98
|
+
onSelected?.(record)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (hideOnEmit) {
|
|
102
|
+
setVisible(false)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}} />
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import SingleCheckboxInputComponent from './SingleCheckboxInputComponent.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemelo de MultiCheckboxInputComponent.vue.
|
|
5
|
+
*
|
|
6
|
+
* La versión Vue recalculaba la selección leyendo el DOM con
|
|
7
|
+
* `document.querySelectorAll`, lo que ataba el componente a que sus inputs
|
|
8
|
+
* estuvieran montados en el documento y rompía con dos grupos del mismo `id`.
|
|
9
|
+
* Aquí la selección sale del propio valor, que es de donde tenía que salir.
|
|
10
|
+
*
|
|
11
|
+
* @param {{id?: string, value: Array, options: Array<{id: any, name: string}>, onChange?: (value: Array) => void}} props
|
|
12
|
+
*/
|
|
13
|
+
export default function MultiCheckboxInputComponent({ id = '', value, options, onChange }) {
|
|
14
|
+
const selected = value ?? []
|
|
15
|
+
|
|
16
|
+
const toggle = (optionId, checked) => {
|
|
17
|
+
onChange?.(checked
|
|
18
|
+
? [...selected, optionId]
|
|
19
|
+
: selected.filter((item) => item !== optionId))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div>
|
|
24
|
+
{options.map((option) => (
|
|
25
|
+
<SingleCheckboxInputComponent
|
|
26
|
+
key={option.id}
|
|
27
|
+
id={id}
|
|
28
|
+
label={option.name}
|
|
29
|
+
value={option.id}
|
|
30
|
+
checked={selected.includes(option.id)}
|
|
31
|
+
onCheckedChange={(checked) => toggle(option.id, checked)} />
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useId, useState } from 'react'
|
|
2
|
+
import CheckboxInputComponent from './CheckboxInputComponent.jsx'
|
|
3
|
+
import EditorInputComponent from './EditorInputComponent.jsx'
|
|
4
|
+
import RadioInputComponent from './RadioInputComponent.jsx'
|
|
5
|
+
import SelectInputComponent from './SelectInputComponent.jsx'
|
|
6
|
+
import SimpleFileInputComponent from './SimpleFileInputComponent.jsx'
|
|
7
|
+
import SwitchComponent from './SwitchComponent.jsx'
|
|
8
|
+
import TextInputComponent from './TextInputComponent.jsx'
|
|
9
|
+
import TextareaInputComponent from './TextareaInputComponent.jsx'
|
|
10
|
+
import useControlled from './internal/useControlled.js'
|
|
11
|
+
|
|
12
|
+
const TEXT_TYPES = ['text', 'number', 'date', 'time', 'url', 'email']
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Gemelo de PolymorphicInputComponent.vue: un solo componente que decide qué
|
|
16
|
+
* control pintar a partir de `config.type`.
|
|
17
|
+
*
|
|
18
|
+
* En Vue el prop se llama literalmente `props`; aquí se llama `config`, que es
|
|
19
|
+
* lo que significa, y se acepta `props` como alias para no romper a quien ya
|
|
20
|
+
* lo pasaba así.
|
|
21
|
+
*/
|
|
22
|
+
export default function PolymorphicInputComponent({
|
|
23
|
+
config,
|
|
24
|
+
props: legacyConfig,
|
|
25
|
+
value,
|
|
26
|
+
onChange,
|
|
27
|
+
onSave,
|
|
28
|
+
onEnter,
|
|
29
|
+
onInput,
|
|
30
|
+
onFocus,
|
|
31
|
+
onBlur,
|
|
32
|
+
}) {
|
|
33
|
+
const settings = config ?? legacyConfig ?? {}
|
|
34
|
+
const uid = useId()
|
|
35
|
+
const [current, set] = useControlled(value, onChange, '')
|
|
36
|
+
const [dirty, setDirty] = useState(false)
|
|
37
|
+
|
|
38
|
+
const update = (next) => {
|
|
39
|
+
setDirty(true)
|
|
40
|
+
set(next)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const saveButton = onSave && dirty ? (
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
className="fe-button fe-button-sm"
|
|
47
|
+
onClick={() => {
|
|
48
|
+
setDirty(false)
|
|
49
|
+
onSave(current)
|
|
50
|
+
}}>
|
|
51
|
+
✓
|
|
52
|
+
</button>
|
|
53
|
+
) : null
|
|
54
|
+
|
|
55
|
+
const control = () => {
|
|
56
|
+
if (TEXT_TYPES.includes(settings.type)) {
|
|
57
|
+
return (
|
|
58
|
+
<TextInputComponent
|
|
59
|
+
label={settings.label}
|
|
60
|
+
icon={settings.icon}
|
|
61
|
+
customClass={settings.customClass}
|
|
62
|
+
type={settings.type}
|
|
63
|
+
name={settings.name}
|
|
64
|
+
placeholder={settings.placeholder}
|
|
65
|
+
validators={settings.validators}
|
|
66
|
+
minLength={settings.minLength}
|
|
67
|
+
maxLength={settings.maxLength}
|
|
68
|
+
readOnly={settings.readonly}
|
|
69
|
+
value={current}
|
|
70
|
+
onChange={update}
|
|
71
|
+
onEnter={onEnter}
|
|
72
|
+
onInput={onInput}
|
|
73
|
+
onFocus={onFocus}
|
|
74
|
+
onBlur={onBlur} />
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
switch (settings.type) {
|
|
79
|
+
case 'textarea':
|
|
80
|
+
return (
|
|
81
|
+
<TextareaInputComponent
|
|
82
|
+
label={settings.label}
|
|
83
|
+
customClass={settings.customClass}
|
|
84
|
+
name={settings.name}
|
|
85
|
+
placeholder={settings.placeholder}
|
|
86
|
+
validators={settings.validators}
|
|
87
|
+
minLength={settings.minLength}
|
|
88
|
+
maxLength={settings.maxLength}
|
|
89
|
+
value={current}
|
|
90
|
+
onChange={update} />
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
case 'radio':
|
|
94
|
+
return (
|
|
95
|
+
<div>
|
|
96
|
+
{settings.label ? <div className="fe-mb"><label>{settings.label}</label></div> : null}
|
|
97
|
+
{(settings.options ?? []).map((option) => (
|
|
98
|
+
<RadioInputComponent
|
|
99
|
+
key={option}
|
|
100
|
+
customClass={settings.customClass}
|
|
101
|
+
name={settings.name}
|
|
102
|
+
validators={settings.validators}
|
|
103
|
+
text={option}
|
|
104
|
+
val={option}
|
|
105
|
+
value={current}
|
|
106
|
+
onChange={update} />
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
case 'checkbox':
|
|
112
|
+
return (
|
|
113
|
+
<div>
|
|
114
|
+
{settings.label ? <div className="fe-mb"><label>{settings.label}</label></div> : null}
|
|
115
|
+
{(settings.options ?? []).map((option) => (
|
|
116
|
+
<CheckboxInputComponent
|
|
117
|
+
key={option}
|
|
118
|
+
customClass={settings.customClass}
|
|
119
|
+
name={settings.name}
|
|
120
|
+
validators={settings.validators}
|
|
121
|
+
text={option}
|
|
122
|
+
val={option}
|
|
123
|
+
value={Array.isArray(current) ? current : []}
|
|
124
|
+
onChange={update} />
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
case 'select':
|
|
130
|
+
return (
|
|
131
|
+
<SelectInputComponent
|
|
132
|
+
label={settings.label}
|
|
133
|
+
customClass={settings.customClass}
|
|
134
|
+
name={settings.name}
|
|
135
|
+
validators={settings.validators}
|
|
136
|
+
value={current}
|
|
137
|
+
onChange={update}>
|
|
138
|
+
<option value="">{settings.placeholder ?? ''}</option>
|
|
139
|
+
{(settings.options ?? []).map((option) => (
|
|
140
|
+
<option key={option.value ?? option} value={option.value ?? option}>
|
|
141
|
+
{option.label ?? option}
|
|
142
|
+
</option>
|
|
143
|
+
))}
|
|
144
|
+
</SelectInputComponent>
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
case 'switch':
|
|
148
|
+
return <SwitchComponent value={Boolean(current)} onChange={update} />
|
|
149
|
+
|
|
150
|
+
case 'editor':
|
|
151
|
+
return (
|
|
152
|
+
<EditorInputComponent
|
|
153
|
+
id={uid}
|
|
154
|
+
name={settings.name}
|
|
155
|
+
label={settings.label}
|
|
156
|
+
value={current}
|
|
157
|
+
onChange={update} />
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
case 'file':
|
|
161
|
+
return (
|
|
162
|
+
<SimpleFileInputComponent
|
|
163
|
+
inputName={settings.name}
|
|
164
|
+
label={settings.label}
|
|
165
|
+
onInput={(file) => update(file)} />
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
default:
|
|
169
|
+
return <div>{`Tipo de campo desconocido: ${settings.type}`}</div>
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div>
|
|
175
|
+
<div className="fe-grid-sm" fe-grid="">
|
|
176
|
+
<div className="fe-w-expand">{control()}</div>
|
|
177
|
+
{saveButton ? <div className="fe-w-auto">{saveButton}</div> : null}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import useControlled from './internal/useControlled.js'
|
|
2
|
+
import useThemeClass from './internal/useThemeClass.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Gemelo de RadioInputComponent.vue. Seleccionado es `value === val`.
|
|
6
|
+
*/
|
|
7
|
+
export default function RadioInputComponent({
|
|
8
|
+
customClass = undefined,
|
|
9
|
+
name,
|
|
10
|
+
validators = null,
|
|
11
|
+
text = '',
|
|
12
|
+
val,
|
|
13
|
+
value,
|
|
14
|
+
onChange,
|
|
15
|
+
children,
|
|
16
|
+
...rest
|
|
17
|
+
}) {
|
|
18
|
+
const [current, set] = useControlled(value, onChange, '')
|
|
19
|
+
const radioClass = useThemeClass('radio', customClass)
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="fe-mb">
|
|
23
|
+
<label className="ml-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
24
|
+
<input
|
|
25
|
+
className={radioClass}
|
|
26
|
+
type="radio"
|
|
27
|
+
name={name}
|
|
28
|
+
data-validators={validators ?? undefined}
|
|
29
|
+
value={val}
|
|
30
|
+
checked={current === val}
|
|
31
|
+
onChange={() => set(val)}
|
|
32
|
+
{...rest} />
|
|
33
|
+
{text}
|
|
34
|
+
{children}
|
|
35
|
+
</label>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useId } from 'react'
|
|
2
|
+
import Field from './internal/Field.jsx'
|
|
3
|
+
import useThemeClass from './internal/useThemeClass.js'
|
|
4
|
+
import useControlled from './internal/useControlled.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Gemelo de SelectInputComponent.vue. Las opciones van como hijos, igual que
|
|
8
|
+
* el slot por defecto en Vue.
|
|
9
|
+
*/
|
|
10
|
+
export default function SelectInputComponent({
|
|
11
|
+
id: providedId = undefined,
|
|
12
|
+
label = '',
|
|
13
|
+
help = null,
|
|
14
|
+
customClass = undefined,
|
|
15
|
+
name,
|
|
16
|
+
multiple = false,
|
|
17
|
+
size = null,
|
|
18
|
+
validators = null,
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
children,
|
|
22
|
+
...rest
|
|
23
|
+
}) {
|
|
24
|
+
const generatedId = useId()
|
|
25
|
+
const uid = providedId ?? generatedId
|
|
26
|
+
const [current, set] = useControlled(value, onChange, multiple ? [] : '')
|
|
27
|
+
const selectClass = useThemeClass('select', customClass)
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Field label={label} help={help} htmlFor={uid}>
|
|
31
|
+
<select
|
|
32
|
+
id={uid}
|
|
33
|
+
className={selectClass}
|
|
34
|
+
name={name}
|
|
35
|
+
multiple={multiple}
|
|
36
|
+
data-validators={validators ?? undefined}
|
|
37
|
+
size={size ?? undefined}
|
|
38
|
+
value={current ?? (multiple ? [] : '')}
|
|
39
|
+
onChange={(event) => {
|
|
40
|
+
// Un select múltiple entrega un array, como el v-model de
|
|
41
|
+
// Vue: quien lo consume no debería notar la diferencia.
|
|
42
|
+
set(multiple
|
|
43
|
+
? Array.from(event.target.selectedOptions, (option) => option.value)
|
|
44
|
+
: event.target.value)
|
|
45
|
+
}}
|
|
46
|
+
{...rest}>
|
|
47
|
+
{children}
|
|
48
|
+
</select>
|
|
49
|
+
</Field>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useCallback, useId, useMemo } from 'react'
|
|
2
|
+
import Select from 'react-select'
|
|
3
|
+
import Field from './internal/Field.jsx'
|
|
4
|
+
import useControlled from './internal/useControlled.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Gemelo de SelectSearchInputComponent.vue.
|
|
8
|
+
*
|
|
9
|
+
* La versión Vue envuelve vue-select; aquí va react-select, que es su
|
|
10
|
+
* equivalente en React: la misma búsqueda, el mismo multiselección, el mismo
|
|
11
|
+
* `appendToBody` (aquí `menuPortalTarget`) y accesibilidad de teclado de
|
|
12
|
+
* serie.
|
|
13
|
+
*
|
|
14
|
+
* El contrato público es el del gemelo Vue: `options`, `label` como nombre de
|
|
15
|
+
* la propiedad que se muestra, `reduce` para quedarse con el valor, y
|
|
16
|
+
* `value` + `onChange(valor)`.
|
|
17
|
+
*/
|
|
18
|
+
export default function SelectSearchInputComponent({
|
|
19
|
+
id: providedId = undefined,
|
|
20
|
+
inputLabel = '',
|
|
21
|
+
help = null,
|
|
22
|
+
customClass = null,
|
|
23
|
+
name,
|
|
24
|
+
options = [],
|
|
25
|
+
label = 'label',
|
|
26
|
+
placeholder = '',
|
|
27
|
+
clearable = true,
|
|
28
|
+
disabled = false,
|
|
29
|
+
multiple = false,
|
|
30
|
+
appendToBody = false,
|
|
31
|
+
loading = false,
|
|
32
|
+
reduce = (option) => option,
|
|
33
|
+
validators = null,
|
|
34
|
+
value,
|
|
35
|
+
onChange,
|
|
36
|
+
onSearch,
|
|
37
|
+
...rest
|
|
38
|
+
}) {
|
|
39
|
+
const generatedId = useId()
|
|
40
|
+
const uid = providedId ?? generatedId
|
|
41
|
+
const [current, set] = useControlled(value, onChange, multiple ? [] : null)
|
|
42
|
+
|
|
43
|
+
const getOptionLabel = useCallback(
|
|
44
|
+
(option) => (typeof option === 'object' && option !== null ? String(option[label] ?? '') : String(option)),
|
|
45
|
+
[label]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// react-select trabaja con la opcion entera; el contrato guarda solo lo
|
|
49
|
+
// que devuelve reduce. Esta es la traduccion entre los dos, y es la razon
|
|
50
|
+
// de que quien consume el componente no note el cambio de libreria.
|
|
51
|
+
const selected = useMemo(() => {
|
|
52
|
+
if (multiple) {
|
|
53
|
+
const wanted = Array.isArray(current) ? current : []
|
|
54
|
+
|
|
55
|
+
return options.filter((option) => wanted.includes(reduce(option)))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return options.find((option) => reduce(option) === current) ?? null
|
|
59
|
+
}, [options, current, multiple, reduce])
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Field label={inputLabel} help={help} htmlFor={uid} inline={false}>
|
|
63
|
+
<Select
|
|
64
|
+
inputId={uid}
|
|
65
|
+
className={customClass ?? undefined}
|
|
66
|
+
classNamePrefix="fe-select"
|
|
67
|
+
options={options}
|
|
68
|
+
getOptionLabel={getOptionLabel}
|
|
69
|
+
getOptionValue={(option) => String(reduce(option))}
|
|
70
|
+
placeholder={placeholder}
|
|
71
|
+
isClearable={clearable}
|
|
72
|
+
isDisabled={disabled}
|
|
73
|
+
isMulti={multiple}
|
|
74
|
+
isLoading={loading}
|
|
75
|
+
aria-label={inputLabel || name}
|
|
76
|
+
// Con `appendToBody` el menu se saca del flujo, que es lo que
|
|
77
|
+
// resuelve los recortes por overflow y los z-index.
|
|
78
|
+
menuPortalTarget={appendToBody && typeof document !== 'undefined' ? document.body : undefined}
|
|
79
|
+
styles={appendToBody ? { menuPortal: (base) => ({ ...base, zIndex: 1015 }) } : undefined}
|
|
80
|
+
value={selected}
|
|
81
|
+
onInputChange={(term, meta) => {
|
|
82
|
+
if (meta.action === 'input-change') {
|
|
83
|
+
onSearch?.(term)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return term
|
|
87
|
+
}}
|
|
88
|
+
onChange={(option) => {
|
|
89
|
+
set(multiple
|
|
90
|
+
? (option ?? []).map((item) => reduce(item))
|
|
91
|
+
: (option ? reduce(option) : null))
|
|
92
|
+
}}
|
|
93
|
+
{...rest} />
|
|
94
|
+
|
|
95
|
+
{/* El validador del proyecto lee data-validators del DOM, y
|
|
96
|
+
react-select no expone un input donde ponerlo. */}
|
|
97
|
+
<input type="hidden" name={name} data-validators={validators ?? undefined} value={
|
|
98
|
+
multiple ? (Array.isArray(current) ? current.join(',') : '') : (current ?? '')
|
|
99
|
+
} readOnly />
|
|
100
|
+
</Field>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useId, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemelo de SimpleFileInputComponent.vue: un botón que abre el diálogo de
|
|
5
|
+
* archivos y muestra el nombre del elegido. `onInput` recibe el File o null.
|
|
6
|
+
*/
|
|
7
|
+
export default function SimpleFileInputComponent({
|
|
8
|
+
customClass = null,
|
|
9
|
+
inputName = 'file',
|
|
10
|
+
label = 'Seleccionar archivo',
|
|
11
|
+
accept = undefined,
|
|
12
|
+
onInput,
|
|
13
|
+
}) {
|
|
14
|
+
const uid = useId()
|
|
15
|
+
const [file, setFile] = useState(null)
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className={['file-select', customClass].filter(Boolean).join(' ')}>
|
|
19
|
+
<label htmlFor={uid} className="select-button" style={{ cursor: 'pointer' }}>
|
|
20
|
+
{file?.name ?? label}
|
|
21
|
+
</label>
|
|
22
|
+
|
|
23
|
+
<input
|
|
24
|
+
id={uid}
|
|
25
|
+
type="file"
|
|
26
|
+
name={inputName}
|
|
27
|
+
accept={accept}
|
|
28
|
+
style={{ display: 'none' }}
|
|
29
|
+
onChange={(event) => {
|
|
30
|
+
// Cancelar el dialogo deja la lista vacia: hay que
|
|
31
|
+
// devolver null, no undefined, o quien lo consume revienta
|
|
32
|
+
// al leer file.size.
|
|
33
|
+
const selected = event.target.files?.[0] ?? null
|
|
34
|
+
|
|
35
|
+
setFile(selected)
|
|
36
|
+
onInput?.(selected)
|
|
37
|
+
}} />
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemelo de SingleCheckboxInputComponent.vue.
|
|
3
|
+
*
|
|
4
|
+
* Es el bloque que usa MultiCheckboxInputComponent para cada opción: el estado
|
|
5
|
+
* lo lleva el padre, aquí solo se avisa del cambio.
|
|
6
|
+
*/
|
|
7
|
+
export default function SingleCheckboxInputComponent({
|
|
8
|
+
id,
|
|
9
|
+
label = '',
|
|
10
|
+
checked = false,
|
|
11
|
+
value = null,
|
|
12
|
+
onCheckedChange,
|
|
13
|
+
...rest
|
|
14
|
+
}) {
|
|
15
|
+
const htmlId = `${id}_${label}`
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="fe-mb">
|
|
19
|
+
<label htmlFor={htmlId} className="ml-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
20
|
+
<input
|
|
21
|
+
id={htmlId}
|
|
22
|
+
className="fe-checkbox"
|
|
23
|
+
type="checkbox"
|
|
24
|
+
name={`input_${id}`}
|
|
25
|
+
checked={checked}
|
|
26
|
+
value={value ?? undefined}
|
|
27
|
+
onChange={(event) => onCheckedChange?.(event.target.checked)}
|
|
28
|
+
{...rest} />
|
|
29
|
+
{label}
|
|
30
|
+
</label>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|