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
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
+ }