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
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# innoboxrr-react-form-elements
|
|
2
|
+
|
|
3
|
+
Gemelo React de [`innoboxrr-form-elements`](../form-elements). Los mismos 29
|
|
4
|
+
componentes, con los mismos nombres.
|
|
5
|
+
|
|
6
|
+
Los nombres coinciden a propósito: `larapack-generator` emite el mismo
|
|
7
|
+
`form_component` del `laraimport.json` para Vue y para React, así que un nombre
|
|
8
|
+
distinto rompería esa simetría. Hay un test (`tests/parity.test.jsx`) que falla
|
|
9
|
+
si un paquete exporta algo que el otro no.
|
|
10
|
+
|
|
11
|
+
## Instalación
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
npm i innoboxrr-react-form-elements
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Los estilos van una vez por aplicación:
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import 'innoboxrr-react-form-elements/src/css/form-elements.css'
|
|
21
|
+
import '@yaireo/tagify/dist/tagify.css' // si usas TagsInputComponent
|
|
22
|
+
import 'react-phone-number-input/style.css' // si usas CountrySelectInputComponent
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Uso
|
|
26
|
+
|
|
27
|
+
```jsx
|
|
28
|
+
import { TextInputComponent, SelectInputComponent } from 'innoboxrr-react-form-elements'
|
|
29
|
+
|
|
30
|
+
<TextInputComponent
|
|
31
|
+
type="text"
|
|
32
|
+
name="title"
|
|
33
|
+
label="Título"
|
|
34
|
+
validators="required"
|
|
35
|
+
value={form.title}
|
|
36
|
+
onChange={(value) => setField('title', value)} />
|
|
37
|
+
|
|
38
|
+
<SelectInputComponent name="status" label="Estado" value={form.status} onChange={...}>
|
|
39
|
+
<option value="">Selecciona</option>
|
|
40
|
+
<option value="draft">Borrador</option>
|
|
41
|
+
</SelectInputComponent>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Equivalencias con la versión Vue
|
|
45
|
+
|
|
46
|
+
| Vue | React |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `v-model` | `value` + `onChange(valor)` |
|
|
49
|
+
| `:custom-class` | `customClass` |
|
|
50
|
+
| `min_length` / `max_length` | `minLength` / `maxLength` (se aceptan también los de guion bajo) |
|
|
51
|
+
| slot por defecto de `SelectInputComponent` | `children` |
|
|
52
|
+
| `v-format` | prop `maskFormat` |
|
|
53
|
+
| `@change` de `CountrySelectInputComponent` | `onCountryChange({ phone, country, callingCode, national, isValid })` |
|
|
54
|
+
| `@submit` / `@selected` de `ModelSearchInputComponent` | `onSubmit` / `onSelected` |
|
|
55
|
+
| `install(app)` | no existe: React no tiene plugin de aplicación |
|
|
56
|
+
|
|
57
|
+
`onChange` recibe el **valor**, no el evento, igual que `update:modelValue`
|
|
58
|
+
recibe el valor y no el `$event`. Para el evento del DOM están `onInput`,
|
|
59
|
+
`onFocus`, `onBlur`, `onEnter` y `onPaste`.
|
|
60
|
+
|
|
61
|
+
Todos los controles funcionan también **sin** `value`: se gobiernan solos. Eso
|
|
62
|
+
permite montarlos en una prueba o en un formulario no controlado sin escribir
|
|
63
|
+
estado alrededor.
|
|
64
|
+
|
|
65
|
+
## Las librerías de debajo
|
|
66
|
+
|
|
67
|
+
Cada componente envuelve el equivalente React de la librería que envuelve su
|
|
68
|
+
gemelo Vue. Donde la librería es agnóstica, es literalmente la misma:
|
|
69
|
+
|
|
70
|
+
| Componente | Vue | React |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `TextInputComponent` (máscara) | `innoboxrr-maskjs/vue` | `innoboxrr-maskjs` — **el mismo motor** |
|
|
73
|
+
| `TagsInputComponent` | `@yaireo/tagify` | `@yaireo/tagify/react` — **la misma librería** |
|
|
74
|
+
| `EditorInputComponent` | `@tinymce/tinymce-vue` | `@tinymce/tinymce-react` — el envoltorio oficial hermano |
|
|
75
|
+
| `CodeMirrorComponent` | `vue-codemirror` | `@uiw/react-codemirror` — el mismo CodeMirror 6 |
|
|
76
|
+
| `SelectSearchInputComponent` | `vue-select` | `react-select` |
|
|
77
|
+
| `CountrySelectInputComponent` | `vue-tel-input` | `react-phone-number-input` — el mismo `libphonenumber-js` |
|
|
78
|
+
| `DynamicGroupInputComponent` | `vuedraggable` | `@dnd-kit/sortable` |
|
|
79
|
+
| `ColorPickerInputComponent` | `lightvue` (opcional) | `react-colorful` |
|
|
80
|
+
|
|
81
|
+
Tres notas sobre esas elecciones:
|
|
82
|
+
|
|
83
|
+
- **`@dnd-kit`** es el sucesor de `react-beautiful-dnd`, que está archivado. A
|
|
84
|
+
diferencia de SortableJS trae **reordenación por teclado**, así que el asa de
|
|
85
|
+
arrastre es un `<button>` alcanzable con tabulador. Un formulario que solo se
|
|
86
|
+
reordena con el ratón no es accesible.
|
|
87
|
+
- **`react-phone-number-input`** valida con `libphonenumber-js`, igual que
|
|
88
|
+
`vue-tel-input`. Sabe cuántos dígitos tiene un número de cada país.
|
|
89
|
+
- **`react-colorful`** pesa 2,8 kB y no tiene dependencias. La versión Vue cae
|
|
90
|
+
a `<input type="color">` cuando `lightvue` no está, que abre el diálogo del
|
|
91
|
+
sistema operativo y no se puede estilar ni probar.
|
|
92
|
+
|
|
93
|
+
## Máscaras
|
|
94
|
+
|
|
95
|
+
```jsx
|
|
96
|
+
<TextInputComponent
|
|
97
|
+
type="text"
|
|
98
|
+
name="phone"
|
|
99
|
+
label="Teléfono"
|
|
100
|
+
maskFormat={{ mask: '(___) ___-____', format: '(***) ***-****' }}
|
|
101
|
+
value={phone}
|
|
102
|
+
onChange={setPhone} />
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
En `format`: `*` es un dígito, `a` una letra, `A` letra o dígito. Todo lo demás
|
|
106
|
+
es un literal. Ver [`innoboxrr-maskjs`](../maskjs).
|
|
107
|
+
|
|
108
|
+
## Diferencias deliberadas
|
|
109
|
+
|
|
110
|
+
- **`MultiCheckboxInputComponent`** deriva la selección del valor. La versión
|
|
111
|
+
Vue la recalculaba con `document.querySelectorAll`, así que dos grupos con el
|
|
112
|
+
mismo `id` se pisaban.
|
|
113
|
+
- **`SelectSearchInputComponent`, `ColorPickerInputComponent`,
|
|
114
|
+
`CodeMirrorComponent` y `EditorInputComponent`** publican su valor en un
|
|
115
|
+
`<input type="hidden">` con el `name` y el `data-validators`. Sus librerías
|
|
116
|
+
no exponen un input donde ponerlos, y el validador del proyecto los lee del
|
|
117
|
+
DOM.
|
|
118
|
+
- **Todos aceptan `id`.** Sin eso, pasar un `id` cambiaba el del control pero
|
|
119
|
+
no el `for` de la etiqueta, y la etiqueta quedaba apuntando a la nada.
|
|
120
|
+
|
|
121
|
+
## Pruebas
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
npm test
|
|
125
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemelo React de innoboxrr-form-elements.
|
|
3
|
+
*
|
|
4
|
+
* Los nombres son los mismos que en la versión Vue a propósito: el generador
|
|
5
|
+
* emite el mismo `form_component` del laraimport para los dos frameworks, así
|
|
6
|
+
* que un nombre distinto rompería esa simetría.
|
|
7
|
+
*
|
|
8
|
+
* React no tiene plugin de aplicación, así que no hay `install()`: se importan
|
|
9
|
+
* los componentes uno a uno. Los estilos van aparte, una vez por aplicación:
|
|
10
|
+
*
|
|
11
|
+
* import 'innoboxrr-react-form-elements/src/css/form-elements.css'
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { default as AvatarInputComponent } from './src/AvatarInputComponent.jsx'
|
|
15
|
+
export { default as ButtonComponent } from './src/ButtonComponent.jsx'
|
|
16
|
+
export { default as CheckboxInputComponent } from './src/CheckboxInputComponent.jsx'
|
|
17
|
+
export { default as ClickToEditComponent } from './src/ClickToEditComponent.jsx'
|
|
18
|
+
export { default as CodeInputComponent } from './src/CodeInputComponent.jsx'
|
|
19
|
+
export { default as CodeMirrorComponent } from './src/CodeMirrorComponent.jsx'
|
|
20
|
+
export { default as ColorPickerInputComponent } from './src/ColorPickerInputComponent.jsx'
|
|
21
|
+
export { default as CountrySelectInputComponent } from './src/CountrySelectInputComponent.jsx'
|
|
22
|
+
export { default as DynamicGroupInputComponent } from './src/DynamicGroupInputComponent.jsx'
|
|
23
|
+
export { default as EditorInputComponent } from './src/EditorInputComponent.jsx'
|
|
24
|
+
export { default as FileDropInputComponent } from './src/FileDropInputComponent.jsx'
|
|
25
|
+
export { default as FileInputComponent } from './src/FileInputComponent.jsx'
|
|
26
|
+
export { default as FqsInputComponent } from './src/FqsInputComponent.jsx'
|
|
27
|
+
export { default as InputErrorComponent } from './src/InputErrorComponent.jsx'
|
|
28
|
+
export { default as ModelSearchInputComponent } from './src/ModelSearchInputComponent.jsx'
|
|
29
|
+
export { default as MultiCheckboxInputComponent } from './src/MultiCheckboxInputComponent.jsx'
|
|
30
|
+
export { default as PolymorphicInputComponent } from './src/PolymorphicInputComponent.jsx'
|
|
31
|
+
export { default as RadioInputComponent } from './src/RadioInputComponent.jsx'
|
|
32
|
+
export { default as SelectInputComponent } from './src/SelectInputComponent.jsx'
|
|
33
|
+
export { default as SelectSearchInputComponent } from './src/SelectSearchInputComponent.jsx'
|
|
34
|
+
export { default as SimpleFileInputComponent } from './src/SimpleFileInputComponent.jsx'
|
|
35
|
+
export { default as SingleCheckboxInputComponent } from './src/SingleCheckboxInputComponent.jsx'
|
|
36
|
+
export { default as StarsInputComponent } from './src/StarsInputComponent.jsx'
|
|
37
|
+
export { default as SwitchComponent } from './src/SwitchComponent.jsx'
|
|
38
|
+
export { default as TagsInputComponent } from './src/TagsInputComponent.jsx'
|
|
39
|
+
export { default as TextEditorMonoStyleInputComponent } from './src/TextEditorMonoStyleInputComponent.jsx'
|
|
40
|
+
export { default as TextInputComponent } from './src/TextInputComponent.jsx'
|
|
41
|
+
export { default as TextareaInputComponent } from './src/TextareaInputComponent.jsx'
|
|
42
|
+
export { default as TimezoneSelectInputComponent } from './src/TimezoneSelectInputComponent.jsx'
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "innoboxrr-react-form-elements",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Gemelo React de innoboxrr-form-elements: los mismos componentes, los mismos nombres y el mismo contrato.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./src/*": "./src/*"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"sideEffects": [
|
|
16
|
+
"*.css"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/innoboxrr/react-form-elements.git"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"react",
|
|
28
|
+
"react19",
|
|
29
|
+
"form",
|
|
30
|
+
"elements",
|
|
31
|
+
"innoboxrr"
|
|
32
|
+
],
|
|
33
|
+
"author": "Homero Raul Vargas Cruz",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"react": "^19.0.0",
|
|
40
|
+
"react-dom": "^19.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@codemirror/lang-css": "^6.3.1",
|
|
44
|
+
"@codemirror/lang-html": "^6.4.12",
|
|
45
|
+
"@codemirror/lang-javascript": "^6.2.5",
|
|
46
|
+
"@codemirror/lang-json": "^6.0.2",
|
|
47
|
+
"@codemirror/theme-one-dark": "^6.1.3",
|
|
48
|
+
"@dnd-kit/core": "^6.3.1",
|
|
49
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
50
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
51
|
+
"@tinymce/tinymce-react": "^6.3.0",
|
|
52
|
+
"@uiw/react-codemirror": "^4.25.11",
|
|
53
|
+
"@yaireo/tagify": "^4.38.0",
|
|
54
|
+
"innoboxrr-form-core": "^2.0.0",
|
|
55
|
+
"innoboxrr-maskjs": "^2.0.0",
|
|
56
|
+
"react-colorful": "^5.8.1",
|
|
57
|
+
"react-phone-number-input": "^3.4.18",
|
|
58
|
+
"react-select": "^5.10.2"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@testing-library/jest-dom": "^6.6.0",
|
|
62
|
+
"@testing-library/react": "^16.1.0",
|
|
63
|
+
"@testing-library/user-event": "^14.5.2",
|
|
64
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
65
|
+
"jsdom": "^25.0.0",
|
|
66
|
+
"react": "^19.0.0",
|
|
67
|
+
"react-dom": "^19.0.0",
|
|
68
|
+
"vite": "^7.1.0",
|
|
69
|
+
"vitest": "^3.0.0"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemelo de AvatarInputComponent.vue: la miniatura redonda que al pulsarla
|
|
5
|
+
* abre el diálogo y sube la imagen. `onUpload` recibe la respuesta del
|
|
6
|
+
* servidor.
|
|
7
|
+
*/
|
|
8
|
+
export default function AvatarInputComponent({
|
|
9
|
+
avatarUrl,
|
|
10
|
+
uploadUrl,
|
|
11
|
+
uploadMethod = 'POST',
|
|
12
|
+
name = 'avatar',
|
|
13
|
+
onUpload,
|
|
14
|
+
}) {
|
|
15
|
+
const input = useRef(null)
|
|
16
|
+
const [preview, setPreview] = useState(null)
|
|
17
|
+
const [uploading, setUploading] = useState(false)
|
|
18
|
+
|
|
19
|
+
const csrfToken = () => globalThis.csrf_token
|
|
20
|
+
?? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
|
21
|
+
?? ''
|
|
22
|
+
|
|
23
|
+
const upload = async (file) => {
|
|
24
|
+
setPreview(URL.createObjectURL(file))
|
|
25
|
+
setUploading(true)
|
|
26
|
+
|
|
27
|
+
const body = new FormData()
|
|
28
|
+
|
|
29
|
+
body.append('_token', csrfToken())
|
|
30
|
+
body.append(name, file)
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(uploadUrl, { method: uploadMethod, body })
|
|
34
|
+
|
|
35
|
+
if (! response.ok) {
|
|
36
|
+
throw new Error('An error has occurred')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
onUpload?.(await response.json())
|
|
40
|
+
} finally {
|
|
41
|
+
setUploading(false)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div>
|
|
47
|
+
<img
|
|
48
|
+
className="fe-avatar-preview"
|
|
49
|
+
src={preview ?? avatarUrl}
|
|
50
|
+
alt="avatar"
|
|
51
|
+
role="button"
|
|
52
|
+
tabIndex={0}
|
|
53
|
+
style={{ cursor: 'pointer', opacity: uploading ? 0.5 : 1 }}
|
|
54
|
+
onClick={() => input.current?.click()}
|
|
55
|
+
onKeyDown={(event) => {
|
|
56
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
57
|
+
input.current?.click()
|
|
58
|
+
}
|
|
59
|
+
}} />
|
|
60
|
+
|
|
61
|
+
<input
|
|
62
|
+
ref={input}
|
|
63
|
+
type="file"
|
|
64
|
+
name={name}
|
|
65
|
+
accept="image/*"
|
|
66
|
+
style={{ display: 'none' }}
|
|
67
|
+
onChange={(event) => {
|
|
68
|
+
const file = event.target.files?.[0]
|
|
69
|
+
|
|
70
|
+
if (file) {
|
|
71
|
+
upload(file)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
event.target.value = ''
|
|
75
|
+
}} />
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import useThemeClass from './internal/useThemeClass.js'
|
|
2
|
+
|
|
3
|
+
const TOKENS = {
|
|
4
|
+
primary: 'button',
|
|
5
|
+
secondary: 'buttonSecondary',
|
|
6
|
+
danger: 'buttonDanger',
|
|
7
|
+
link: 'buttonLink',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Gemelo de ButtonComponent.vue.
|
|
12
|
+
*
|
|
13
|
+
* `value` es el texto, como en Vue. Se acepta también `children` para el caso
|
|
14
|
+
* en que haya que meter un icono dentro.
|
|
15
|
+
*
|
|
16
|
+
* `variant` elige el token del tema. Sin él, un formulario generado tendría
|
|
17
|
+
* que escribir la clase del botón secundario a mano, que es justo lo que el
|
|
18
|
+
* tema viene a evitar.
|
|
19
|
+
*/
|
|
20
|
+
export default function ButtonComponent({
|
|
21
|
+
variant = 'primary',
|
|
22
|
+
customClass = undefined,
|
|
23
|
+
disabled = false,
|
|
24
|
+
value,
|
|
25
|
+
type = 'submit',
|
|
26
|
+
onClick,
|
|
27
|
+
children,
|
|
28
|
+
...rest
|
|
29
|
+
}) {
|
|
30
|
+
const className = useThemeClass(TOKENS[variant] ?? 'button', customClass)
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="fe-mb">
|
|
34
|
+
<button
|
|
35
|
+
type={type}
|
|
36
|
+
className={className}
|
|
37
|
+
disabled={disabled}
|
|
38
|
+
onClick={onClick}
|
|
39
|
+
{...rest}>
|
|
40
|
+
{children ?? value}
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import useControlled from './internal/useControlled.js'
|
|
2
|
+
import useThemeClass from './internal/useThemeClass.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Gemelo de CheckboxInputComponent.vue.
|
|
6
|
+
*
|
|
7
|
+
* Como en Vue, el comportamiento depende del valor enlazado: con un array se
|
|
8
|
+
* comporta como casilla de un grupo y añade o quita `val`; con cualquier otra
|
|
9
|
+
* cosa es un booleano.
|
|
10
|
+
*/
|
|
11
|
+
export default function CheckboxInputComponent({
|
|
12
|
+
customClass = undefined,
|
|
13
|
+
name,
|
|
14
|
+
validators = null,
|
|
15
|
+
text = '',
|
|
16
|
+
val = null,
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
children,
|
|
20
|
+
...rest
|
|
21
|
+
}) {
|
|
22
|
+
const [current, set] = useControlled(value, onChange, '')
|
|
23
|
+
const boxClass = useThemeClass('checkbox', customClass)
|
|
24
|
+
|
|
25
|
+
const isGroup = Array.isArray(current)
|
|
26
|
+
const checked = isGroup ? current.includes(val) : Boolean(current)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="fe-mb">
|
|
30
|
+
<label className="ml-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
31
|
+
<input
|
|
32
|
+
className={boxClass}
|
|
33
|
+
type="checkbox"
|
|
34
|
+
name={name}
|
|
35
|
+
data-validators={validators ?? undefined}
|
|
36
|
+
value={val ?? undefined}
|
|
37
|
+
checked={checked}
|
|
38
|
+
onChange={(event) => {
|
|
39
|
+
if (! isGroup) {
|
|
40
|
+
set(event.target.checked)
|
|
41
|
+
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
set(event.target.checked
|
|
46
|
+
? [...current, val]
|
|
47
|
+
: current.filter((item) => item !== val))
|
|
48
|
+
}}
|
|
49
|
+
{...rest} />
|
|
50
|
+
{text}
|
|
51
|
+
{children}
|
|
52
|
+
</label>
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemelo de ClickToEditComponent.vue: texto que se convierte en input al
|
|
5
|
+
* hacer clic. `onInput` recibe el valor confirmado.
|
|
6
|
+
*/
|
|
7
|
+
export default function ClickToEditComponent({ value = '', onInput, customClass = 'fe-input' }) {
|
|
8
|
+
const [editing, setEditing] = useState(false)
|
|
9
|
+
const [draft, setDraft] = useState(value)
|
|
10
|
+
const input = useRef(null)
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
setDraft(value)
|
|
14
|
+
}, [value])
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (editing) {
|
|
18
|
+
input.current?.focus()
|
|
19
|
+
}
|
|
20
|
+
}, [editing])
|
|
21
|
+
|
|
22
|
+
const confirm = () => {
|
|
23
|
+
setEditing(false)
|
|
24
|
+
onInput?.(draft)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cancel = () => {
|
|
28
|
+
setDraft(value)
|
|
29
|
+
setEditing(false)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (! editing) {
|
|
33
|
+
return (
|
|
34
|
+
<span
|
|
35
|
+
role="button"
|
|
36
|
+
tabIndex={0}
|
|
37
|
+
onClick={() => setEditing(true)}
|
|
38
|
+
onKeyDown={(event) => {
|
|
39
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
40
|
+
setEditing(true)
|
|
41
|
+
}
|
|
42
|
+
}}>
|
|
43
|
+
{value}
|
|
44
|
+
</span>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<input
|
|
50
|
+
ref={input}
|
|
51
|
+
className={customClass}
|
|
52
|
+
value={draft}
|
|
53
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
54
|
+
onBlur={confirm}
|
|
55
|
+
onKeyDown={(event) => {
|
|
56
|
+
if (event.key === 'Enter') {
|
|
57
|
+
confirm()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (event.key === 'Escape') {
|
|
61
|
+
cancel()
|
|
62
|
+
}
|
|
63
|
+
}} />
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import useControlled from './internal/useControlled.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Gemelo de CodeInputComponent.vue: las casillas de un código de un solo uso.
|
|
6
|
+
*
|
|
7
|
+
* `onChange` recibe el código completo, y `onComplete` se dispara cuando están
|
|
8
|
+
* todas las casillas llenas — que es cuando el formulario quiere enviarlo.
|
|
9
|
+
*/
|
|
10
|
+
export default function CodeInputComponent({
|
|
11
|
+
fields = 6,
|
|
12
|
+
fieldWidth = 40,
|
|
13
|
+
fieldHeight = 40,
|
|
14
|
+
title = null,
|
|
15
|
+
className = '',
|
|
16
|
+
required = false,
|
|
17
|
+
disabled = false,
|
|
18
|
+
autoFocus = false,
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
onComplete,
|
|
22
|
+
}) {
|
|
23
|
+
const [code, setCode] = useControlled(value, onChange, '')
|
|
24
|
+
const inputs = useRef([])
|
|
25
|
+
const [chars, setChars] = useState(() => Array.from({ length: fields }, (_, i) => (code ?? '')[i] ?? ''))
|
|
26
|
+
|
|
27
|
+
const commit = (next) => {
|
|
28
|
+
setChars(next)
|
|
29
|
+
|
|
30
|
+
const joined = next.join('')
|
|
31
|
+
|
|
32
|
+
setCode(joined)
|
|
33
|
+
|
|
34
|
+
if (joined.length === fields && ! next.includes('')) {
|
|
35
|
+
onComplete?.(joined)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const focus = (index) => {
|
|
40
|
+
inputs.current[index]?.focus()
|
|
41
|
+
inputs.current[index]?.select?.()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={['code-input-container', className].filter(Boolean).join(' ')}>
|
|
46
|
+
{title ? <p className="title">{title}</p> : null}
|
|
47
|
+
|
|
48
|
+
<div className="code-input fe-code-input">
|
|
49
|
+
{chars.map((char, index) => (
|
|
50
|
+
<input
|
|
51
|
+
key={index}
|
|
52
|
+
ref={(element) => { inputs.current[index] = element }}
|
|
53
|
+
className="w-14 h-14 rounded-lg border border-gray outline-none focus:outline-none focus:border-primary focus:ring-0 text-center transition-all"
|
|
54
|
+
type="text"
|
|
55
|
+
inputMode="numeric"
|
|
56
|
+
pattern="[0-9]*"
|
|
57
|
+
maxLength={1}
|
|
58
|
+
style={{ width: `${fieldWidth}px`, height: `${fieldHeight}px` }}
|
|
59
|
+
autoFocus={autoFocus && index === 0}
|
|
60
|
+
data-id={index}
|
|
61
|
+
required={required}
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
value={char}
|
|
64
|
+
onFocus={(event) => event.target.select()}
|
|
65
|
+
onChange={(event) => {
|
|
66
|
+
// Pegar el codigo entero en la primera casilla es
|
|
67
|
+
// lo que hace todo el mundo; se reparte.
|
|
68
|
+
const typed = event.target.value.replace(/\D/g, '')
|
|
69
|
+
|
|
70
|
+
if (typed.length > 1) {
|
|
71
|
+
const next = [...chars]
|
|
72
|
+
|
|
73
|
+
typed.split('').slice(0, fields - index).forEach((digit, offset) => {
|
|
74
|
+
next[index + offset] = digit
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
commit(next)
|
|
78
|
+
focus(Math.min(index + typed.length, fields - 1))
|
|
79
|
+
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const next = [...chars]
|
|
84
|
+
next[index] = typed
|
|
85
|
+
commit(next)
|
|
86
|
+
|
|
87
|
+
if (typed && index < fields - 1) {
|
|
88
|
+
focus(index + 1)
|
|
89
|
+
}
|
|
90
|
+
}}
|
|
91
|
+
onKeyDown={(event) => {
|
|
92
|
+
if (event.key === 'Backspace' && ! chars[index] && index > 0) {
|
|
93
|
+
focus(index - 1)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (event.key === 'ArrowLeft' && index > 0) {
|
|
97
|
+
focus(index - 1)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (event.key === 'ArrowRight' && index < fields - 1) {
|
|
101
|
+
focus(index + 1)
|
|
102
|
+
}
|
|
103
|
+
}} />
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import CodeMirror from '@uiw/react-codemirror'
|
|
3
|
+
import { oneDark } from '@codemirror/theme-one-dark'
|
|
4
|
+
import { css } from '@codemirror/lang-css'
|
|
5
|
+
import { html } from '@codemirror/lang-html'
|
|
6
|
+
import { javascript } from '@codemirror/lang-javascript'
|
|
7
|
+
import { json } from '@codemirror/lang-json'
|
|
8
|
+
|
|
9
|
+
import Field from './internal/Field.jsx'
|
|
10
|
+
import useControlled from './internal/useControlled.js'
|
|
11
|
+
|
|
12
|
+
const LANGUAGES = { javascript, json, html, css }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Gemelo de CodeMirrorComponent.vue.
|
|
16
|
+
*
|
|
17
|
+
* La versión Vue usa vue-codemirror sobre CodeMirror 6; aquí va
|
|
18
|
+
* `@uiw/react-codemirror`, que es el binding equivalente y el estándar de
|
|
19
|
+
* hecho en React. Los mismos modos de lenguaje y el mismo tema `one-dark` que
|
|
20
|
+
* declara el gemelo, así que un mismo bloque de código se ve igual en los dos.
|
|
21
|
+
*
|
|
22
|
+
* Antes esto montaba CodeMirror a mano con un `useEffect` y caía a un
|
|
23
|
+
* `<textarea>` si las dependencias no estaban. Eran opcionales, y por tanto
|
|
24
|
+
* casi nunca estaban.
|
|
25
|
+
*/
|
|
26
|
+
export default function CodeMirrorComponent({
|
|
27
|
+
label = '',
|
|
28
|
+
help = null,
|
|
29
|
+
name,
|
|
30
|
+
language = 'javascript',
|
|
31
|
+
height = '300px',
|
|
32
|
+
readOnly = false,
|
|
33
|
+
theme = 'dark',
|
|
34
|
+
validators = null,
|
|
35
|
+
value,
|
|
36
|
+
onChange,
|
|
37
|
+
...rest
|
|
38
|
+
}) {
|
|
39
|
+
const [current, set] = useControlled(value, onChange, '')
|
|
40
|
+
|
|
41
|
+
const extensions = useMemo(() => {
|
|
42
|
+
const support = LANGUAGES[language]
|
|
43
|
+
|
|
44
|
+
return support ? [support()] : []
|
|
45
|
+
}, [language])
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Field label={label} help={help} inline={false}>
|
|
49
|
+
<CodeMirror
|
|
50
|
+
value={current ?? ''}
|
|
51
|
+
height={height}
|
|
52
|
+
readOnly={readOnly}
|
|
53
|
+
theme={theme === 'dark' ? oneDark : 'light'}
|
|
54
|
+
extensions={extensions}
|
|
55
|
+
onChange={set}
|
|
56
|
+
{...rest} />
|
|
57
|
+
|
|
58
|
+
{/* El validador del proyecto lee data-validators del DOM. */}
|
|
59
|
+
<input type="hidden" name={name} data-validators={validators ?? undefined} value={current ?? ''} readOnly />
|
|
60
|
+
</Field>
|
|
61
|
+
)
|
|
62
|
+
}
|