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,49 @@
1
+ import { useState } from 'react'
2
+ import useControlled from './internal/useControlled.js'
3
+
4
+ /**
5
+ * Gemelo de StarsInputComponent.vue.
6
+ *
7
+ * La versión Vue montaba un radio nativo por estrella y pintaba encima con
8
+ * CSS. Aquí son botones: el control ya es accesible por teclado sin depender
9
+ * de la hoja de estilos, y el valor sigue siendo el mismo número.
10
+ */
11
+ export default function StarsInputComponent({
12
+ max = 5,
13
+ value,
14
+ onChange,
15
+ name = 'rating',
16
+ char = '★',
17
+ inactiveChar = null,
18
+ readOnly = false,
19
+ starsSize = '50px',
20
+ ...rest
21
+ }) {
22
+ const [current, set] = useControlled(value, onChange, 0)
23
+ const [hovered, setHovered] = useState(null)
24
+
25
+ const shown = hovered ?? current ?? 0
26
+
27
+ return (
28
+ <div
29
+ className="fe-stars"
30
+ style={{ fontSize: starsSize }}
31
+ onMouseLeave={() => setHovered(null)}
32
+ {...rest}>
33
+ {Array.from({ length: max }, (_, index) => index + 1).map((position) => (
34
+ <button
35
+ key={position}
36
+ type="button"
37
+ name={`${name}${position}`}
38
+ disabled={readOnly}
39
+ aria-label={`${position}`}
40
+ aria-pressed={position <= (current ?? 0)}
41
+ data-active={position <= shown}
42
+ onMouseEnter={() => (readOnly ? null : setHovered(position))}
43
+ onClick={() => (readOnly ? null : set(position))}>
44
+ {position <= shown ? char : (inactiveChar ?? char)}
45
+ </button>
46
+ ))}
47
+ </div>
48
+ )
49
+ }
@@ -0,0 +1,25 @@
1
+ import useControlled from './internal/useControlled.js'
2
+
3
+ /**
4
+ * Gemelo de SwitchComponent.vue.
5
+ *
6
+ * Los estilos `.fe-switch` van en form-elements.css: en Vue estaban en un
7
+ * <style scoped> y React no tiene equivalente.
8
+ */
9
+ export default function SwitchComponent({ value, onChange, onToggle, ...rest }) {
10
+ const [current, set] = useControlled(value, onChange, false)
11
+
12
+ return (
13
+ <label className="fe-switch">
14
+ <input
15
+ type="checkbox"
16
+ checked={Boolean(current)}
17
+ onChange={(event) => {
18
+ set(event.target.checked)
19
+ onToggle?.(event)
20
+ }}
21
+ {...rest} />
22
+ <div className="fe-switch-slider fe-switch-lg"></div>
23
+ </label>
24
+ )
25
+ }
@@ -0,0 +1,88 @@
1
+ import { useCallback, useId, useMemo, useRef } from 'react'
2
+ import Tags from '@yaireo/tagify/react'
3
+ import Field from './internal/Field.jsx'
4
+ import useThemeClass from './internal/useThemeClass.js'
5
+ import useControlled from './internal/useControlled.js'
6
+
7
+ /**
8
+ * Gemelo de TagsInputComponent.vue.
9
+ *
10
+ * Es literalmente la misma librería que usa la versión Vue —Tagify—, con su
11
+ * envoltorio oficial de React. Eso mantiene el mismo comportamiento, el mismo
12
+ * marcado y la misma hoja de estilos (`@yaireo/tagify/dist/tagify.css`), que
13
+ * es lo que importa para que un formulario generado se vea igual en los dos
14
+ * frameworks.
15
+ *
16
+ * El valor sigue siendo un array de cadenas, como en Vue. Acepta también una
17
+ * cadena separada por comas.
18
+ */
19
+ export default function TagsInputComponent({
20
+ id: providedId = undefined,
21
+ label = '',
22
+ help = null,
23
+ customClass = undefined,
24
+ name,
25
+ placeholder = '',
26
+ validators = null,
27
+ whitelist = undefined,
28
+ maxTags = undefined,
29
+ duplicates = false,
30
+ value,
31
+ onChange,
32
+ tagifyRef,
33
+ }) {
34
+ const generatedId = useId()
35
+ const uid = providedId ?? generatedId
36
+ const [current, set] = useControlled(value, onChange, [])
37
+ const inputClass = useThemeClass('input', customClass)
38
+ const latest = useRef(null)
39
+
40
+ latest.current = set
41
+
42
+ const tags = useMemo(() => (
43
+ Array.isArray(current)
44
+ ? current
45
+ : String(current ?? '').split(',').map((tag) => tag.trim()).filter(Boolean)
46
+ ), [current])
47
+
48
+ const settings = useMemo(() => {
49
+ const options = {
50
+ placeholder,
51
+ duplicates,
52
+ // Tagify emite objetos {value}; el contrato guarda cadenas.
53
+ originalInputValueFormat: (values) => values.map((tag) => tag.value).join(','),
54
+ }
55
+
56
+ // Tagify no distingue "no lo pases" de "pasalo como undefined": con
57
+ // whitelist a undefined revienta al filtrar sugerencias.
58
+ if (whitelist !== undefined) {
59
+ options.whitelist = whitelist
60
+ }
61
+
62
+ if (maxTags !== undefined) {
63
+ options.maxTags = maxTags
64
+ }
65
+
66
+ return options
67
+ }, [placeholder, whitelist, maxTags, duplicates])
68
+
69
+ const onTagifyChange = useCallback((event) => {
70
+ const raw = event.detail?.value ?? ''
71
+
72
+ latest.current(raw ? raw.split(',').map((tag) => tag.trim()).filter(Boolean) : [])
73
+ }, [])
74
+
75
+ return (
76
+ <Field label={label} help={help} htmlFor={uid}>
77
+ <Tags
78
+ id={uid}
79
+ name={name}
80
+ className={inputClass}
81
+ data-validators={validators ?? undefined}
82
+ settings={settings}
83
+ value={tags}
84
+ tagifyRef={tagifyRef}
85
+ onChange={onTagifyChange} />
86
+ </Field>
87
+ )
88
+ }
@@ -0,0 +1,27 @@
1
+ import CodeMirrorComponent from './CodeMirrorComponent.jsx'
2
+
3
+ /**
4
+ * Gemelo de TextEditorMonoStyleInputComponent.vue: el editor monoespaciado
5
+ * para plantillas HTML.
6
+ */
7
+ export default function TextEditorMonoStyleInputComponent({
8
+ label = '',
9
+ help = null,
10
+ name,
11
+ height = '400px',
12
+ readOnly = false,
13
+ value,
14
+ onChange,
15
+ }) {
16
+ return (
17
+ <CodeMirrorComponent
18
+ label={label}
19
+ help={help}
20
+ name={name}
21
+ language="html"
22
+ height={height}
23
+ readOnly={readOnly}
24
+ value={value}
25
+ onChange={onChange} />
26
+ )
27
+ }
@@ -0,0 +1,117 @@
1
+ import { useId, useState } from 'react'
2
+ import { applyMask, isMaskSpec } from 'innoboxrr-maskjs'
3
+ import Field from './internal/Field.jsx'
4
+ import useThemeClass from './internal/useThemeClass.js'
5
+ import useControlled from './internal/useControlled.js'
6
+
7
+ /**
8
+ * Gemelo de TextInputComponent.vue.
9
+ *
10
+ * Mismos nombres de prop salvo los que React escribe en camelCase: se aceptan
11
+ * también `min_length` y `max_length` porque son los que declara el laraimport
12
+ * y los que emite el generador de Vue.
13
+ */
14
+ export default function TextInputComponent({
15
+ id: providedId = undefined,
16
+ label = '',
17
+ help = null,
18
+ icon = '',
19
+ customClass = undefined,
20
+ type,
21
+ name,
22
+ placeholder = null,
23
+ autoFocus = undefined,
24
+ autoComplete = undefined,
25
+ validators = null,
26
+ minLength = null,
27
+ maxLength = null,
28
+ min_length = null,
29
+ max_length = null,
30
+ steps = null,
31
+ readOnly = undefined,
32
+ maskFormat = null,
33
+ value,
34
+ onChange,
35
+ onEnter,
36
+ onInput,
37
+ onFocus,
38
+ onBlur,
39
+ onPaste,
40
+ ...rest
41
+ }) {
42
+ const generatedId = useId()
43
+ const uid = providedId ?? generatedId
44
+ const [current, set] = useControlled(value, onChange, '')
45
+ const [showPassword, setShowPassword] = useState(false)
46
+ const inputClass = useThemeClass('input', customClass)
47
+
48
+ const isPassword = type === 'password'
49
+ const effectiveType = isPassword ? (showPassword ? 'text' : 'password') : type
50
+
51
+ const minimum = minLength ?? min_length
52
+ const maximum = maxLength ?? max_length
53
+
54
+ const hasIcon = icon !== '' && icon != null
55
+
56
+ // El equivalente de la directiva v-format de Vue. Un input controlado no
57
+ // necesita el hook de maskjs: basta con formatear el valor de entrada,
58
+ // porque applyMask es pura e idempotente.
59
+ const masked = isMaskSpec(maskFormat)
60
+
61
+ const shown = masked ? applyMask(current, maskFormat).value : (current ?? '')
62
+
63
+ return (
64
+ <Field label={label} help={help} htmlFor={uid}>
65
+ {hasIcon ? <span className="fe-field-icon" uk-icon={`icon: ${icon}`}></span> : null}
66
+
67
+ <div className="fe-input-wrap">
68
+ <input
69
+ id={uid}
70
+ data-uid={uid}
71
+ className={[inputClass, isPassword ? 'fe-has-toggle' : ''].filter(Boolean).join(' ')}
72
+ type={effectiveType}
73
+ name={name}
74
+ placeholder={placeholder ?? undefined}
75
+ autoFocus={autoFocus ?? undefined}
76
+ autoComplete={autoComplete ?? undefined}
77
+ data-validators={validators ?? undefined}
78
+ data-mask={masked ? maskFormat.mask : undefined}
79
+ data-format={masked ? maskFormat.format : undefined}
80
+ data-min_length={minimum ?? undefined}
81
+ data-max_length={maximum ?? undefined}
82
+ min={minimum ?? undefined}
83
+ max={maximum ?? undefined}
84
+ step={steps ?? undefined}
85
+ readOnly={readOnly ?? undefined}
86
+ value={shown}
87
+ onKeyUp={(event) => {
88
+ if (event.key === 'Enter' && onEnter) {
89
+ onEnter(event)
90
+ }
91
+ }}
92
+ onChange={(event) => {
93
+ set(masked ? applyMask(event.target.value, maskFormat).value : event.target.value)
94
+
95
+ if (onInput) {
96
+ onInput(event)
97
+ }
98
+ }}
99
+ onFocus={onFocus}
100
+ onBlur={onBlur}
101
+ onPaste={onPaste}
102
+ {...rest} />
103
+
104
+ {isPassword ? (
105
+ <button
106
+ type="button"
107
+ tabIndex={-1}
108
+ className="fe-password-toggle"
109
+ aria-label={showPassword ? 'Hide password' : 'Show password'}
110
+ onClick={() => setShowPassword((shown) => ! shown)}>
111
+ <i className={showPassword ? 'fa-solid fa-eye-slash' : 'fa-solid fa-eye'}></i>
112
+ </button>
113
+ ) : null}
114
+ </div>
115
+ </Field>
116
+ )
117
+ }
@@ -0,0 +1,50 @@
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 TextareaInputComponent.vue.
8
+ */
9
+ export default function TextareaInputComponent({
10
+ id: providedId = undefined,
11
+ label = '',
12
+ help = null,
13
+ customClass = undefined,
14
+ rows = 5,
15
+ name,
16
+ placeholder = null,
17
+ validators = null,
18
+ minLength = null,
19
+ maxLength = null,
20
+ min_length = null,
21
+ max_length = null,
22
+ value,
23
+ onChange,
24
+ ...rest
25
+ }) {
26
+ const generatedId = useId()
27
+ const uid = providedId ?? generatedId
28
+ const [current, set] = useControlled(value, onChange, '')
29
+ const areaClass = useThemeClass('textarea', customClass)
30
+
31
+ const minimum = minLength ?? min_length
32
+ const maximum = maxLength ?? max_length
33
+
34
+ return (
35
+ <Field label={label} help={help} htmlFor={uid}>
36
+ <textarea
37
+ id={uid}
38
+ className={areaClass}
39
+ rows={rows}
40
+ name={name}
41
+ placeholder={placeholder ?? undefined}
42
+ data-validators={validators ?? undefined}
43
+ data-min_length={minimum ?? undefined}
44
+ data-max_length={maximum ?? undefined}
45
+ value={current ?? ''}
46
+ onChange={(event) => set(event.target.value)}
47
+ {...rest}></textarea>
48
+ </Field>
49
+ )
50
+ }
@@ -0,0 +1,32 @@
1
+ import SelectSearchInputComponent from './SelectSearchInputComponent.jsx'
2
+ import { timezones } from 'innoboxrr-form-core'
3
+
4
+ /**
5
+ * Gemelo de TimezoneSelectInputComponent.vue: el buscador con la lista de
6
+ * zonas horarias ya cargada. El valor que sale es la cadena IANA.
7
+ */
8
+ export default function TimezoneSelectInputComponent({
9
+ label = '',
10
+ placeholder = 'Select a timezone',
11
+ help = null,
12
+ name,
13
+ validators = '',
14
+ value,
15
+ onChange,
16
+ ...rest
17
+ }) {
18
+ return (
19
+ <SelectSearchInputComponent
20
+ name={name}
21
+ inputLabel={label}
22
+ help={help}
23
+ validators={validators}
24
+ placeholder={placeholder}
25
+ options={timezones}
26
+ label="label"
27
+ reduce={(option) => option.value}
28
+ value={value}
29
+ onChange={onChange}
30
+ {...rest} />
31
+ )
32
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Los estilos que en el paquete Vue viven en los bloques <style scoped>.
3
+ *
4
+ * React no tiene scoped styles, así que se publican como una hoja única que la
5
+ * aplicación importa una vez. Los selectores llevan prefijo `fe-` para que no
6
+ * choquen con nada del anfitrión.
7
+ */
8
+
9
+ .fe-input-wrap {
10
+ position: relative;
11
+ width: 100%;
12
+ }
13
+
14
+ /* Deja sitio a la derecha para que el texto no pase por debajo del ojo. */
15
+ .fe-input-wrap .fe-has-toggle {
16
+ padding-right: 2.75rem;
17
+ }
18
+
19
+ .fe-password-toggle {
20
+ position: absolute;
21
+ top: 50%;
22
+ right: 0.75rem;
23
+ transform: translateY(-50%);
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ width: 1.75rem;
28
+ height: 1.75rem;
29
+ padding: 0;
30
+ margin: 0;
31
+ background: transparent;
32
+ border: none;
33
+ border-radius: 9999px;
34
+ cursor: pointer;
35
+ color: #6b7280;
36
+ transition: color 0.15s ease, background-color 0.15s ease;
37
+ z-index: 2;
38
+ }
39
+
40
+ .fe-password-toggle:hover {
41
+ color: #111827;
42
+ background-color: rgba(0, 0, 0, 0.05);
43
+ }
44
+
45
+ .fe-password-toggle:focus {
46
+ outline: none;
47
+ }
48
+
49
+ .dark .fe-password-toggle,
50
+ html.dark .fe-password-toggle {
51
+ color: #94a3b8;
52
+ }
53
+
54
+ .dark .fe-password-toggle:hover,
55
+ html.dark .fe-password-toggle:hover {
56
+ color: #f1f5f9;
57
+ background-color: rgba(255, 255, 255, 0.08);
58
+ }
59
+
60
+ .fe-input-error {
61
+ margin: -20px 0 0 2px;
62
+ font-size: 0.8em;
63
+ }
64
+
65
+ .fe-stars {
66
+ display: inline-flex;
67
+ gap: 0.25rem;
68
+ cursor: pointer;
69
+ }
70
+
71
+ .fe-stars button {
72
+ background: transparent;
73
+ border: none;
74
+ padding: 0;
75
+ cursor: pointer;
76
+ color: #d1d5db;
77
+ line-height: 1;
78
+ }
79
+
80
+ .fe-stars button[data-active='true'] {
81
+ color: #f59e0b;
82
+ }
83
+
84
+ .fe-file-drop {
85
+ border: 2px dashed #d1d5db;
86
+ border-radius: 0.5rem;
87
+ padding: 1.5rem;
88
+ text-align: center;
89
+ transition: border-color 0.15s ease, background-color 0.15s ease;
90
+ }
91
+
92
+ .fe-file-drop[data-dragging='true'] {
93
+ border-color: #3b82f6;
94
+ background-color: rgba(59, 130, 246, 0.05);
95
+ }
96
+
97
+ .fe-avatar-preview {
98
+ width: 8rem;
99
+ height: 8rem;
100
+ border-radius: 9999px;
101
+ object-fit: cover;
102
+ }
103
+
104
+ .fe-dynamic-group-row {
105
+ display: flex;
106
+ gap: 0.5rem;
107
+ align-items: flex-start;
108
+ margin-bottom: 0.5rem;
109
+ }
110
+
111
+ .fe-dynamic-group-row > *:first-child {
112
+ flex: 1 1 auto;
113
+ }
114
+
115
+ /* SwitchComponent: en Vue estos estilos iban en su <style scoped>. */
116
+
117
+ .fe-switch {
118
+ position: relative;
119
+ display: inline-block;
120
+ height: 19px;
121
+ width: 30px;
122
+ }
123
+
124
+ .fe-switch input {
125
+ display: none;
126
+ }
127
+
128
+ .fe-switch-slider {
129
+ background-color: rgba(0, 0, 0, 0.22);
130
+ position: absolute;
131
+ top: 0;
132
+ left: 0;
133
+ right: 0;
134
+ border-radius: 500px;
135
+ bottom: 0;
136
+ cursor: pointer;
137
+ transition-property: background-color;
138
+ transition-duration: 0.2s;
139
+ box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.07);
140
+ }
141
+
142
+ .fe-switch-slider:before {
143
+ content: '';
144
+ background-color: #fff;
145
+ position: absolute;
146
+ width: 15px;
147
+ height: 15px;
148
+ left: 2px;
149
+ bottom: 2px;
150
+ border-radius: 50%;
151
+ transition-property: transform, box-shadow;
152
+ transition-duration: 0.2s;
153
+ }
154
+
155
+ .fe-switch input:checked + .fe-switch-slider {
156
+ background-color: #39f !important;
157
+ }
158
+
159
+ .fe-switch input:checked + .fe-switch-slider:before {
160
+ transform: translateX(13px);
161
+ }
162
+
163
+ .fe-switch-slider.fe-switch-lg:before {
164
+ transform: scale(1.2);
165
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.22);
166
+ }
167
+
168
+ .fe-switch input:checked + .fe-switch-slider.fe-switch-lg:before {
169
+ transform: translateX(13px) scale(1.2);
170
+ }
171
+
172
+ /* CodeInputComponent */
173
+
174
+ .fe-code-input {
175
+ display: flex;
176
+ gap: 0.5rem;
177
+ }
178
+
179
+ .fe-code-input input::-webkit-outer-spin-button,
180
+ .fe-code-input input::-webkit-inner-spin-button {
181
+ -webkit-appearance: none;
182
+ margin: 0;
183
+ }
184
+
185
+ .fe-code-input input {
186
+ -moz-appearance: textfield;
187
+ }
@@ -0,0 +1,21 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import { getTheme, onThemeChange } from 'innoboxrr-form-core'
3
+ import FieldLabel from './FieldLabel.jsx'
4
+
5
+ /**
6
+ * El envoltorio `fe-mb > fe-inline` que llevan casi todos los controles.
7
+ */
8
+ export default function Field({ label, help, htmlFor, children, inline = true }) {
9
+ const theme = useSyncExternalStore(onThemeChange, () => getTheme(), () => getTheme())
10
+
11
+ const body = inline
12
+ ? <div className={theme.fieldInner}>{children}</div>
13
+ : children
14
+
15
+ return (
16
+ <div className={theme.field}>
17
+ {(label || help) ? <FieldLabel label={label} help={help} htmlFor={htmlFor} /> : null}
18
+ {body}
19
+ </div>
20
+ )
21
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * La etiqueta con su icono de ayuda.
3
+ *
4
+ * En el paquete Vue este bloque está copiado en los treinta componentes. El
5
+ * marcado es idéntico a propósito: las dos versiones comparten el mismo CSS
6
+ * (UIkit + Tailwind) del proyecto anfitrión, así que cambiarlo aquí
7
+ * descuadraría los formularios generados para React respecto a los de Vue.
8
+ */
9
+ import { useSyncExternalStore } from 'react'
10
+ import { getTheme, onThemeChange } from 'innoboxrr-form-core'
11
+
12
+ export default function FieldLabel({ label, help, htmlFor = undefined }) {
13
+ const theme = useSyncExternalStore(onThemeChange, () => getTheme(), () => getTheme())
14
+
15
+ if (! label && ! help) {
16
+ return null
17
+ }
18
+
19
+ return (
20
+ <label htmlFor={htmlFor} className={theme.label}>
21
+ {help ? (
22
+ <span className={theme.help}>
23
+ <i uk-tooltip={`title: ${help}`} className={theme.helpIcon}></i>
24
+ </span>
25
+ ) : null}
26
+ {label}
27
+ </label>
28
+ )
29
+ }
@@ -0,0 +1,38 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * `value` + `onChange(valor)` es el equivalente React de `v-model`: onChange
5
+ * recibe el valor, no el evento, igual que `update:modelValue` recibe el valor
6
+ * y no el `$event`. El generador emite las dos formas desde el mismo
7
+ * laraimport, así que tienen que significar lo mismo.
8
+ *
9
+ * Si no llega `value` el componente se gobierna solo. Eso permite montarlo en
10
+ * una prueba o en un formulario no controlado sin escribir estado alrededor, y
11
+ * evita el aviso de React por pasar de no controlado a controlado.
12
+ *
13
+ * @template T
14
+ * @param {T|undefined} value
15
+ * @param {((next: T) => void)|undefined} onChange
16
+ * @param {T} fallback
17
+ * @returns {[T, (next: T) => void]}
18
+ */
19
+ export default function useControlled(value, onChange, fallback = '') {
20
+ // El modo se fija en el primer render: cambiarlo a mitad de vida es
21
+ // justamente lo que provoca el aviso de React.
22
+ const controlled = useRef(value !== undefined)
23
+ const [internal, setInternal] = useState(value !== undefined ? value : fallback)
24
+
25
+ const current = controlled.current ? value : internal
26
+
27
+ const set = useCallback((next) => {
28
+ if (! controlled.current) {
29
+ setInternal(next)
30
+ }
31
+
32
+ if (onChange) {
33
+ onChange(next)
34
+ }
35
+ }, [onChange])
36
+
37
+ return [current, set]
38
+ }
@@ -0,0 +1,22 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import { classFor, getTheme, onThemeChange } from 'innoboxrr-form-core'
3
+
4
+ /**
5
+ * La clase de un control según el tema, dejando que `customClass` mande.
6
+ *
7
+ * Va por `useSyncExternalStore` y no por una lectura directa porque el tema es
8
+ * estado de módulo: si alguien llama a `setTheme` con la aplicación ya montada
9
+ * —cambiar de claro a oscuro, por ejemplo—, lo leído directamente no
10
+ * repintaría nada.
11
+ *
12
+ * @param {string} token
13
+ * @param {string|null|undefined} customClass
14
+ * @returns {string}
15
+ */
16
+ export default function useThemeClass(token, customClass) {
17
+ const theme = useSyncExternalStore(onThemeChange, () => getTheme(), () => getTheme())
18
+
19
+ return customClass ?? (theme[token] ?? '')
20
+ }
21
+
22
+ export { classFor }