innoboxrr-react-form-elements 3.3.0 → 3.5.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 +37 -4
- package/index.js +7 -0
- package/package.json +4 -3
- package/src/CheckboxInputComponent.jsx +2 -1
- package/src/ClickToEditComponent.jsx +116 -41
- package/src/CodeInputComponent.jsx +4 -2
- package/src/CommandPaletteComponent.jsx +182 -0
- package/src/ConfirmHostComponent.jsx +50 -0
- package/src/CountrySelectInputComponent.jsx +1 -1
- package/src/DialogComponent.jsx +43 -0
- package/src/DrawerComponent.jsx +42 -0
- package/src/DynamicGroupInputComponent.jsx +56 -50
- package/src/FileDropInputComponent.jsx +12 -2
- package/src/FileInputComponent.jsx +2 -2
- package/src/FqsInputComponent.jsx +1 -1
- package/src/MenuComponent.jsx +252 -0
- package/src/RadioInputComponent.jsx +2 -1
- package/src/SingleCheckboxInputComponent.jsx +4 -1
- package/src/SkeletonComponent.jsx +36 -0
- package/src/ToastRegionComponent.jsx +102 -0
- package/src/internal/DialogShell.jsx +80 -0
- package/src/internal/useNativeDialog.js +97 -0
- package/src/internal/useTheme.js +21 -0
package/README.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# innoboxrr-react-form-elements
|
|
2
2
|
|
|
3
|
-
Gemelo React de [`innoboxrr-form-elements`](../form-elements). Los mismos
|
|
4
|
-
componentes, con los mismos nombres
|
|
3
|
+
Gemelo React de [`innoboxrr-form-elements`](../form-elements). Los mismos 37
|
|
4
|
+
componentes, con los mismos nombres: los controles de formulario y las piezas de
|
|
5
|
+
escritorio (`DialogComponent`, `DrawerComponent`, `MenuComponent`,
|
|
6
|
+
`CommandPaletteComponent`, `ToastRegionComponent`, `ConfirmHostComponent` y
|
|
7
|
+
`SkeletonComponent`).
|
|
5
8
|
|
|
6
9
|
Los nombres coinciden a propósito: `larapack-generator` emite el mismo
|
|
7
10
|
`form_component` del `laraimport.json` para Vue y para React, así que un nombre
|
|
8
|
-
distinto rompería esa simetría. Hay un test
|
|
9
|
-
si un paquete exporta algo que el otro no.
|
|
11
|
+
distinto rompería esa simetría. Hay un test de paridad en cada repositorio que
|
|
12
|
+
falla si un paquete exporta algo que el otro no.
|
|
10
13
|
|
|
11
14
|
## Instalación
|
|
12
15
|
|
|
@@ -62,6 +65,36 @@ Todos los controles funcionan también **sin** `value`: se gobiernan solos. Eso
|
|
|
62
65
|
permite montarlos en una prueba o en un formulario no controlado sin escribir
|
|
63
66
|
estado alrededor.
|
|
64
67
|
|
|
68
|
+
## Piezas de escritorio
|
|
69
|
+
|
|
70
|
+
Sobre `<dialog>`, el atributo `popover` y Floating UI, con el mismo
|
|
71
|
+
comportamiento que la rama Vue. Lo que cambia es la forma de pasar las cosas:
|
|
72
|
+
|
|
73
|
+
| Vue | React |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `v-model:open` | `open` + `onOpenChange(abierto)` |
|
|
76
|
+
| slot `footer` / `header` con `{ close }` | prop `footer` / `header`: un nodo o una función `({ close }) => …` |
|
|
77
|
+
| slot `trigger` de `MenuComponent` | `renderTrigger({ toggle, open, loading, triggerProps })` |
|
|
78
|
+
| `@select` | `onSelect(item)` |
|
|
79
|
+
| `:save` de `ClickToEditComponent` | `onSave(valor)` |
|
|
80
|
+
|
|
81
|
+
```jsx
|
|
82
|
+
import { DrawerComponent, ToastRegionComponent, ConfirmHostComponent } from 'innoboxrr-react-form-elements'
|
|
83
|
+
import { notifySuccess } from 'innoboxrr-form-core'
|
|
84
|
+
|
|
85
|
+
<DrawerComponent open={abierto} onOpenChange={setAbierto} title="Nuevo producto"
|
|
86
|
+
footer={({ close }) => <button type="button" onClick={close}>Cancelar</button>}>
|
|
87
|
+
<CreateForm onSubmit={(producto) => { notifySuccess('Producto creado'); setAbierto(false) }} />
|
|
88
|
+
</DrawerComponent>
|
|
89
|
+
|
|
90
|
+
// una vez, en la raíz
|
|
91
|
+
<ToastRegionComponent />
|
|
92
|
+
<ConfirmHostComponent />
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
React no escribe el atributo `autofocus` en el DOM, así que lo que tiene que
|
|
96
|
+
recibir el foco al abrir un diálogo se marca con `data-autofocus`.
|
|
97
|
+
|
|
65
98
|
## Las librerías de debajo
|
|
66
99
|
|
|
67
100
|
Cada componente envuelve el equivalente React de la librería que envuelve su
|
package/index.js
CHANGED
|
@@ -18,7 +18,11 @@ export { default as ClickToEditComponent } from './src/ClickToEditComponent.jsx'
|
|
|
18
18
|
export { default as CodeInputComponent } from './src/CodeInputComponent.jsx'
|
|
19
19
|
export { default as CodeMirrorComponent } from './src/CodeMirrorComponent.jsx'
|
|
20
20
|
export { default as ColorPickerInputComponent } from './src/ColorPickerInputComponent.jsx'
|
|
21
|
+
export { default as CommandPaletteComponent } from './src/CommandPaletteComponent.jsx'
|
|
22
|
+
export { default as ConfirmHostComponent } from './src/ConfirmHostComponent.jsx'
|
|
21
23
|
export { default as CountrySelectInputComponent } from './src/CountrySelectInputComponent.jsx'
|
|
24
|
+
export { default as DialogComponent } from './src/DialogComponent.jsx'
|
|
25
|
+
export { default as DrawerComponent } from './src/DrawerComponent.jsx'
|
|
22
26
|
export { default as DynamicGroupInputComponent } from './src/DynamicGroupInputComponent.jsx'
|
|
23
27
|
export { default as EditorInputComponent } from './src/EditorInputComponent.jsx'
|
|
24
28
|
export { default as FileDropInputComponent } from './src/FileDropInputComponent.jsx'
|
|
@@ -26,6 +30,7 @@ export { default as IconComponent } from './src/IconComponent.jsx'
|
|
|
26
30
|
export { default as FileInputComponent } from './src/FileInputComponent.jsx'
|
|
27
31
|
export { default as FqsInputComponent } from './src/FqsInputComponent.jsx'
|
|
28
32
|
export { default as InputErrorComponent } from './src/InputErrorComponent.jsx'
|
|
33
|
+
export { default as MenuComponent } from './src/MenuComponent.jsx'
|
|
29
34
|
export { default as ModelSearchInputComponent } from './src/ModelSearchInputComponent.jsx'
|
|
30
35
|
export { default as MultiCheckboxInputComponent } from './src/MultiCheckboxInputComponent.jsx'
|
|
31
36
|
export { default as PolymorphicInputComponent } from './src/PolymorphicInputComponent.jsx'
|
|
@@ -34,6 +39,7 @@ export { default as SelectInputComponent } from './src/SelectInputComponent.jsx'
|
|
|
34
39
|
export { default as SelectSearchInputComponent } from './src/SelectSearchInputComponent.jsx'
|
|
35
40
|
export { default as SimpleFileInputComponent } from './src/SimpleFileInputComponent.jsx'
|
|
36
41
|
export { default as SingleCheckboxInputComponent } from './src/SingleCheckboxInputComponent.jsx'
|
|
42
|
+
export { default as SkeletonComponent } from './src/SkeletonComponent.jsx'
|
|
37
43
|
export { default as StarsInputComponent } from './src/StarsInputComponent.jsx'
|
|
38
44
|
export { default as SwitchComponent } from './src/SwitchComponent.jsx'
|
|
39
45
|
export { default as TagsInputComponent } from './src/TagsInputComponent.jsx'
|
|
@@ -41,3 +47,4 @@ export { default as TextEditorMonoStyleInputComponent } from './src/TextEditorMo
|
|
|
41
47
|
export { default as TextInputComponent } from './src/TextInputComponent.jsx'
|
|
42
48
|
export { default as TextareaInputComponent } from './src/TextareaInputComponent.jsx'
|
|
43
49
|
export { default as TimezoneSelectInputComponent } from './src/TimezoneSelectInputComponent.jsx'
|
|
50
|
+
export { default as ToastRegionComponent } from './src/ToastRegionComponent.jsx'
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "innoboxrr-react-form-elements",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "Gemelo React de innoboxrr-form-elements: los mismos componentes, los mismos nombres y el mismo contrato.",
|
|
3
|
+
"version": "3.5.0",
|
|
4
|
+
"description": "Gemelo React de innoboxrr-form-elements: los mismos componentes, los mismos nombres y el mismo contrato, con diálogos, drawers, menús, avisos y paleta de comandos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
@@ -48,11 +48,12 @@
|
|
|
48
48
|
"@dnd-kit/core": "^6.3.1",
|
|
49
49
|
"@dnd-kit/sortable": "^10.0.0",
|
|
50
50
|
"@dnd-kit/utilities": "^3.2.2",
|
|
51
|
+
"@floating-ui/dom": "^1.8.0",
|
|
51
52
|
"@iconify/react": "^6.0.0",
|
|
52
53
|
"@tinymce/tinymce-react": "^6.3.0",
|
|
53
54
|
"@uiw/react-codemirror": "^4.25.11",
|
|
54
55
|
"@yaireo/tagify": "^4.38.0",
|
|
55
|
-
"innoboxrr-form-core": "^2.
|
|
56
|
+
"innoboxrr-form-core": "^2.7.0",
|
|
56
57
|
"innoboxrr-maskjs": "^2.0.0",
|
|
57
58
|
"react-colorful": "^5.8.1",
|
|
58
59
|
"react-phone-number-input": "^3.4.18",
|
|
@@ -21,13 +21,14 @@ export default function CheckboxInputComponent({
|
|
|
21
21
|
}) {
|
|
22
22
|
const [current, set] = useControlled(value, onChange, '')
|
|
23
23
|
const boxClass = useThemeClass('checkbox', customClass)
|
|
24
|
+
const labelClass = useThemeClass('label')
|
|
24
25
|
|
|
25
26
|
const isGroup = Array.isArray(current)
|
|
26
27
|
const checked = isGroup ? current.includes(val) : Boolean(current)
|
|
27
28
|
|
|
28
29
|
return (
|
|
29
30
|
<div className="fe-mb">
|
|
30
|
-
<label className=
|
|
31
|
+
<label className={labelClass}>
|
|
31
32
|
<input
|
|
32
33
|
className={boxClass}
|
|
33
34
|
type="checkbox"
|
|
@@ -1,65 +1,140 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import useTheme from './internal/useTheme.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Gemelo de ClickToEditComponent.vue:
|
|
5
|
-
*
|
|
5
|
+
* Gemelo de ClickToEditComponent.vue: un valor que se edita donde está.
|
|
6
|
+
*
|
|
7
|
+
* <ClickToEditComponent value={producto.title} onSave={(title) => updateModel(producto.id, { title })} />
|
|
8
|
+
*
|
|
9
|
+
* Enter o salir del campo confirman; Escape cancela. Un valor sin cambios no
|
|
10
|
+
* llama a nada.
|
|
11
|
+
*
|
|
12
|
+
* Con `onSave`, la confirmación espera a que termine: mientras guarda el campo
|
|
13
|
+
* no se puede tocar, y si falla se queda abierto con el error. `onInput` sigue
|
|
14
|
+
* recibiendo el valor confirmado, y `customClass` sigue reemplazando la clase
|
|
15
|
+
* del campo.
|
|
6
16
|
*/
|
|
7
|
-
export default function ClickToEditComponent({
|
|
17
|
+
export default function ClickToEditComponent({
|
|
18
|
+
value = '',
|
|
19
|
+
type = 'text',
|
|
20
|
+
placeholder = '—',
|
|
21
|
+
label = 'Editar',
|
|
22
|
+
onSave = null,
|
|
23
|
+
onInput,
|
|
24
|
+
customClass = undefined,
|
|
25
|
+
}) {
|
|
26
|
+
const theme = useTheme()
|
|
8
27
|
const [editing, setEditing] = useState(false)
|
|
9
|
-
const [
|
|
28
|
+
const [saving, setSaving] = useState(false)
|
|
29
|
+
const [error, setError] = useState(null)
|
|
30
|
+
const [draft, setDraft] = useState('')
|
|
31
|
+
const [shown, setShown] = useState(value)
|
|
10
32
|
const input = useRef(null)
|
|
11
33
|
|
|
34
|
+
// Enter confirma y quita el campo, y quitarlo puede disparar blur: el
|
|
35
|
+
// estado de React aún no se ha actualizado en ese momento, así que la
|
|
36
|
+
// guarda va en una ref.
|
|
37
|
+
const status = useRef({ editing: false, saving: false })
|
|
38
|
+
|
|
12
39
|
useEffect(() => {
|
|
13
|
-
|
|
40
|
+
setShown(value)
|
|
14
41
|
}, [value])
|
|
15
42
|
|
|
16
43
|
useEffect(() => {
|
|
17
|
-
if (editing) {
|
|
44
|
+
if (editing && ! saving) {
|
|
18
45
|
input.current?.focus()
|
|
19
46
|
}
|
|
20
|
-
}, [editing])
|
|
47
|
+
}, [editing, saving, error])
|
|
48
|
+
|
|
49
|
+
const setEditingState = (next) => {
|
|
50
|
+
status.current.editing = next
|
|
51
|
+
setEditing(next)
|
|
52
|
+
}
|
|
21
53
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
54
|
+
const start = () => {
|
|
55
|
+
setDraft(shown ?? '')
|
|
56
|
+
setError(null)
|
|
57
|
+
setEditingState(true)
|
|
25
58
|
}
|
|
26
59
|
|
|
27
60
|
const cancel = () => {
|
|
28
|
-
|
|
29
|
-
|
|
61
|
+
setEditingState(false)
|
|
62
|
+
setError(null)
|
|
30
63
|
}
|
|
31
64
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
)
|
|
65
|
+
const confirm = async () => {
|
|
66
|
+
if (! status.current.editing || status.current.saving) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const next = draft
|
|
71
|
+
|
|
72
|
+
if (String(next) === String(shown ?? '')) {
|
|
73
|
+
setEditingState(false)
|
|
74
|
+
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (onSave) {
|
|
79
|
+
status.current.saving = true
|
|
80
|
+
setSaving(true)
|
|
81
|
+
setError(null)
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await onSave(next)
|
|
85
|
+
} catch (failure) {
|
|
86
|
+
status.current.saving = false
|
|
87
|
+
setSaving(false)
|
|
88
|
+
setError(failure?.message || 'No se pudo guardar')
|
|
89
|
+
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
status.current.saving = false
|
|
94
|
+
setSaving(false)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
setShown(next)
|
|
98
|
+
setEditingState(false)
|
|
99
|
+
onInput?.(next)
|
|
46
100
|
}
|
|
47
101
|
|
|
102
|
+
const empty = shown === '' || shown === null || shown === undefined
|
|
103
|
+
const text = empty ? placeholder : shown
|
|
104
|
+
|
|
48
105
|
return (
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
106
|
+
<span className="fe-inline">
|
|
107
|
+
|
|
108
|
+
{editing ? (
|
|
109
|
+
<input
|
|
110
|
+
ref={input}
|
|
111
|
+
type={type}
|
|
112
|
+
className={customClass ?? theme.input}
|
|
113
|
+
aria-label={label}
|
|
114
|
+
aria-invalid={error ? 'true' : undefined}
|
|
115
|
+
disabled={saving}
|
|
116
|
+
value={draft}
|
|
117
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
118
|
+
onBlur={confirm}
|
|
119
|
+
onKeyDown={(event) => {
|
|
120
|
+
if (event.key === 'Enter') {
|
|
121
|
+
event.preventDefault()
|
|
122
|
+
confirm()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (event.key === 'Escape') {
|
|
126
|
+
event.preventDefault()
|
|
127
|
+
cancel()
|
|
128
|
+
}
|
|
129
|
+
}} />
|
|
130
|
+
) : (
|
|
131
|
+
<button type="button" className={theme.editable} aria-label={`${label}: ${text}`} onClick={start}>
|
|
132
|
+
{text}
|
|
133
|
+
</button>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{error ? <span className={theme.error} role="alert">{error}</span> : null}
|
|
137
|
+
|
|
138
|
+
</span>
|
|
64
139
|
)
|
|
65
140
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useRef, useState } from 'react'
|
|
2
2
|
import useControlled from './internal/useControlled.js'
|
|
3
|
+
import useTheme, { joinClasses } from './internal/useTheme.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Gemelo de CodeInputComponent.vue: las casillas de un código de un solo uso.
|
|
@@ -20,6 +21,7 @@ export default function CodeInputComponent({
|
|
|
20
21
|
onChange,
|
|
21
22
|
onComplete,
|
|
22
23
|
}) {
|
|
24
|
+
const theme = useTheme()
|
|
23
25
|
const [code, setCode] = useControlled(value, onChange, '')
|
|
24
26
|
const inputs = useRef([])
|
|
25
27
|
const [chars, setChars] = useState(() => Array.from({ length: fields }, (_, i) => (code ?? '')[i] ?? ''))
|
|
@@ -45,12 +47,12 @@ export default function CodeInputComponent({
|
|
|
45
47
|
<div className={['code-input-container', className].filter(Boolean).join(' ')}>
|
|
46
48
|
{title ? <p className="title">{title}</p> : null}
|
|
47
49
|
|
|
48
|
-
<div className=
|
|
50
|
+
<div className={joinClasses('code-input', theme.codeInput)}>
|
|
49
51
|
{chars.map((char, index) => (
|
|
50
52
|
<input
|
|
51
53
|
key={index}
|
|
52
54
|
ref={(element) => { inputs.current[index] = element }}
|
|
53
|
-
className=
|
|
55
|
+
className={theme.codeCell}
|
|
54
56
|
type="text"
|
|
55
57
|
inputMode="numeric"
|
|
56
58
|
pattern="[0-9]*"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import IconComponent from './IconComponent.jsx'
|
|
3
|
+
import useNativeDialog from './internal/useNativeDialog.js'
|
|
4
|
+
import useTheme, { joinClasses } from './internal/useTheme.js'
|
|
5
|
+
|
|
6
|
+
const normalize = (value) => String(value ?? '')
|
|
7
|
+
.normalize('NFD')
|
|
8
|
+
.replace(/[̀-ͯ]/g, '')
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Gemelo de CommandPaletteComponent.vue: buscar y ejecutar cualquier cosa sin
|
|
13
|
+
* tocar el ratón.
|
|
14
|
+
*
|
|
15
|
+
* <CommandPaletteComponent open={abierta} onOpenChange={setAbierta} items={[
|
|
16
|
+
* { id: 'products', label: 'Productos', group: 'Ir a', icon: 'box', action: () => navigate(…) },
|
|
17
|
+
* ]} onSelect={…} />
|
|
18
|
+
*
|
|
19
|
+
* Ctrl+K o Cmd+K la abren y la cierran; `hotkey` cambia la tecla y `null` quita
|
|
20
|
+
* el atajo. El filtro no distingue mayúsculas ni acentos.
|
|
21
|
+
*/
|
|
22
|
+
export default function CommandPaletteComponent({
|
|
23
|
+
open = false,
|
|
24
|
+
items = [],
|
|
25
|
+
placeholder = 'Buscar…',
|
|
26
|
+
emptyText = 'Sin resultados',
|
|
27
|
+
label = 'Paleta de comandos',
|
|
28
|
+
hotkey = 'k',
|
|
29
|
+
onOpenChange,
|
|
30
|
+
onSelect,
|
|
31
|
+
}) {
|
|
32
|
+
const theme = useTheme()
|
|
33
|
+
const listId = useId()
|
|
34
|
+
const input = useRef(null)
|
|
35
|
+
const [query, setQuery] = useState('')
|
|
36
|
+
const [active, setActive] = useState(0)
|
|
37
|
+
|
|
38
|
+
const latest = useRef({ open, hotkey, onOpenChange })
|
|
39
|
+
|
|
40
|
+
latest.current = { open, hotkey, onOpenChange }
|
|
41
|
+
|
|
42
|
+
const requestClose = () => onOpenChange?.(false)
|
|
43
|
+
|
|
44
|
+
const { ref, onClick } = useNativeDialog({ open, dismissible: true, onRequestClose: requestClose })
|
|
45
|
+
|
|
46
|
+
const filtered = useMemo(() => {
|
|
47
|
+
const needle = normalize(query).trim()
|
|
48
|
+
|
|
49
|
+
if (needle === '') {
|
|
50
|
+
return items
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return items.filter((item) => normalize([item.label, item.group, ...(item.keywords ?? [])].join(' ')).includes(needle))
|
|
54
|
+
}, [items, query])
|
|
55
|
+
|
|
56
|
+
const sections = useMemo(() => {
|
|
57
|
+
const groups = new Map()
|
|
58
|
+
|
|
59
|
+
filtered.forEach((item, index) => {
|
|
60
|
+
const key = item.group ?? null
|
|
61
|
+
|
|
62
|
+
if (! groups.has(key)) {
|
|
63
|
+
groups.set(key, [])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
groups.get(key).push({ item, index })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return [...groups].map(([group, entries]) => ({ group, entries }))
|
|
70
|
+
}, [filtered])
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (open) {
|
|
74
|
+
setQuery('')
|
|
75
|
+
setActive(0)
|
|
76
|
+
input.current?.focus()
|
|
77
|
+
}
|
|
78
|
+
}, [open])
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const onHotkey = (event) => {
|
|
82
|
+
const { hotkey: key, open: isOpen, onOpenChange: change } = latest.current
|
|
83
|
+
|
|
84
|
+
if (key && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === key.toLowerCase()) {
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
change?.(! isOpen)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
window.addEventListener('keydown', onHotkey)
|
|
91
|
+
|
|
92
|
+
return () => window.removeEventListener('keydown', onHotkey)
|
|
93
|
+
}, [])
|
|
94
|
+
|
|
95
|
+
const optionId = (index) => `${listId}-${index}`
|
|
96
|
+
|
|
97
|
+
const choose = (item) => {
|
|
98
|
+
if (! item) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
requestClose()
|
|
103
|
+
onSelect?.(item)
|
|
104
|
+
item.action?.(item)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const onKeyDown = (event) => {
|
|
108
|
+
const total = filtered.length
|
|
109
|
+
|
|
110
|
+
if (event.key === 'ArrowDown' && total > 0) {
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
setActive((current) => (current + 1) % total)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (event.key === 'ArrowUp' && total > 0) {
|
|
116
|
+
event.preventDefault()
|
|
117
|
+
setActive((current) => (current - 1 + total) % total)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (event.key === 'Enter') {
|
|
121
|
+
event.preventDefault()
|
|
122
|
+
choose(filtered[active])
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<dialog
|
|
128
|
+
ref={ref}
|
|
129
|
+
className={joinClasses(theme.dialog, theme.command)}
|
|
130
|
+
aria-label={label}
|
|
131
|
+
onClick={onClick}>
|
|
132
|
+
|
|
133
|
+
{open ? (
|
|
134
|
+
<>
|
|
135
|
+
<input
|
|
136
|
+
ref={input}
|
|
137
|
+
type="text"
|
|
138
|
+
className={theme.commandInput}
|
|
139
|
+
placeholder={placeholder}
|
|
140
|
+
value={query}
|
|
141
|
+
role="combobox"
|
|
142
|
+
aria-autocomplete="list"
|
|
143
|
+
aria-expanded="true"
|
|
144
|
+
aria-controls={listId}
|
|
145
|
+
aria-activedescendant={filtered.length > 0 ? optionId(active) : undefined}
|
|
146
|
+
onChange={(event) => {
|
|
147
|
+
setQuery(event.target.value)
|
|
148
|
+
setActive(0)
|
|
149
|
+
}}
|
|
150
|
+
onKeyDown={onKeyDown} />
|
|
151
|
+
|
|
152
|
+
{filtered.length > 0 ? (
|
|
153
|
+
<ul id={listId} className={theme.commandList} role="listbox" aria-label={label}>
|
|
154
|
+
{sections.map((section) => [
|
|
155
|
+
section.group
|
|
156
|
+
? <li key={`group-${section.group}`} role="presentation" className={theme.commandGroup}>{section.group}</li>
|
|
157
|
+
: null,
|
|
158
|
+
...section.entries.map((entry) => (
|
|
159
|
+
<li
|
|
160
|
+
key={entry.item.id ?? entry.index}
|
|
161
|
+
id={optionId(entry.index)}
|
|
162
|
+
role="option"
|
|
163
|
+
aria-selected={entry.index === active ? 'true' : 'false'}
|
|
164
|
+
className={theme.commandItem}
|
|
165
|
+
onClick={() => choose(entry.item)}
|
|
166
|
+
onMouseMove={() => setActive(entry.index)}>
|
|
167
|
+
{entry.item.icon ? <IconComponent name={entry.item.icon} size={16} /> : null}
|
|
168
|
+
<span>{entry.item.label}</span>
|
|
169
|
+
{entry.item.shortcut ? <kbd className={theme.kbd}>{entry.item.shortcut}</kbd> : null}
|
|
170
|
+
</li>
|
|
171
|
+
)),
|
|
172
|
+
])}
|
|
173
|
+
</ul>
|
|
174
|
+
) : (
|
|
175
|
+
<p className={theme.commandEmpty}>{emptyText}</p>
|
|
176
|
+
)}
|
|
177
|
+
</>
|
|
178
|
+
) : null}
|
|
179
|
+
|
|
180
|
+
</dialog>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import { getConfirmation, onConfirmationChange, resolveConfirmation } from 'innoboxrr-form-core'
|
|
3
|
+
import DialogShell from './internal/DialogShell.jsx'
|
|
4
|
+
import useTheme from './internal/useTheme.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Gemelo de ConfirmHostComponent.vue: donde se pregunta lo que pide
|
|
8
|
+
* `confirmAction()`. Se monta una vez, en la raíz de la aplicación.
|
|
9
|
+
*
|
|
10
|
+
* Cerrar con Escape o con un clic fuera cuenta como cancelar, y el foco empieza
|
|
11
|
+
* en cancelar: un Enter por inercia no debe borrar nada.
|
|
12
|
+
*/
|
|
13
|
+
export default function ConfirmHostComponent({ closeLabel = 'Cerrar' }) {
|
|
14
|
+
const confirmation = useSyncExternalStore(onConfirmationChange, getConfirmation, getConfirmation)
|
|
15
|
+
const theme = useTheme()
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<DialogShell
|
|
19
|
+
kind="dialog"
|
|
20
|
+
size="sm"
|
|
21
|
+
open={confirmation !== null}
|
|
22
|
+
title={confirmation?.title ?? null}
|
|
23
|
+
closeLabel={closeLabel}
|
|
24
|
+
onOpenChange={(value) => {
|
|
25
|
+
if (! value) {
|
|
26
|
+
resolveConfirmation(false)
|
|
27
|
+
}
|
|
28
|
+
}}
|
|
29
|
+
footer={(
|
|
30
|
+
<>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
className={theme.buttonSecondary}
|
|
34
|
+
data-autofocus=""
|
|
35
|
+
onClick={() => resolveConfirmation(false)}>
|
|
36
|
+
{confirmation?.cancelLabel}
|
|
37
|
+
</button>
|
|
38
|
+
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
className={confirmation?.variant === 'danger' ? theme.buttonDanger : theme.button}
|
|
42
|
+
onClick={() => resolveConfirmation(true)}>
|
|
43
|
+
{confirmation?.confirmLabel}
|
|
44
|
+
</button>
|
|
45
|
+
</>
|
|
46
|
+
)}>
|
|
47
|
+
<p>{confirmation?.message}</p>
|
|
48
|
+
</DialogShell>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import DialogShell from './internal/DialogShell.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemelo de DialogComponent.vue: un diálogo modal sobre <dialog>.
|
|
5
|
+
*
|
|
6
|
+
* <DialogComponent open={abierto} onOpenChange={setAbierto} title="Borrar producto"
|
|
7
|
+
* footer={({ close }) => <button onClick={close}>Cancelar</button>}>
|
|
8
|
+
* ¿Seguro?
|
|
9
|
+
* </DialogComponent>
|
|
10
|
+
*
|
|
11
|
+
* `dismissible={false}` impide cerrarlo con Escape, con un clic fuera y quita
|
|
12
|
+
* la X.
|
|
13
|
+
*/
|
|
14
|
+
export default function DialogComponent({
|
|
15
|
+
open = false,
|
|
16
|
+
title = null,
|
|
17
|
+
label = null,
|
|
18
|
+
size = 'md',
|
|
19
|
+
dismissible = true,
|
|
20
|
+
closeLabel = 'Cerrar',
|
|
21
|
+
onOpenChange,
|
|
22
|
+
onClose,
|
|
23
|
+
header = null,
|
|
24
|
+
footer = null,
|
|
25
|
+
children,
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<DialogShell
|
|
29
|
+
kind="dialog"
|
|
30
|
+
open={open}
|
|
31
|
+
title={title}
|
|
32
|
+
label={label}
|
|
33
|
+
size={size}
|
|
34
|
+
dismissible={dismissible}
|
|
35
|
+
closeLabel={closeLabel}
|
|
36
|
+
onOpenChange={onOpenChange}
|
|
37
|
+
onClose={onClose}
|
|
38
|
+
header={header}
|
|
39
|
+
footer={footer}>
|
|
40
|
+
{children}
|
|
41
|
+
</DialogShell>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import DialogShell from './internal/DialogShell.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemelo de DrawerComponent.vue: un panel lateral modal sobre <dialog>, para
|
|
5
|
+
* crear o editar sin salir de la tabla.
|
|
6
|
+
*
|
|
7
|
+
* <DrawerComponent open={abierto} onOpenChange={setAbierto} title="Nuevo producto">
|
|
8
|
+
* <CreateForm onSubmit={…} />
|
|
9
|
+
* </DrawerComponent>
|
|
10
|
+
*
|
|
11
|
+
* `side="start"` lo pega a la izquierda.
|
|
12
|
+
*/
|
|
13
|
+
export default function DrawerComponent({
|
|
14
|
+
open = false,
|
|
15
|
+
title = null,
|
|
16
|
+
label = null,
|
|
17
|
+
side = 'end',
|
|
18
|
+
dismissible = true,
|
|
19
|
+
closeLabel = 'Cerrar',
|
|
20
|
+
onOpenChange,
|
|
21
|
+
onClose,
|
|
22
|
+
header = null,
|
|
23
|
+
footer = null,
|
|
24
|
+
children,
|
|
25
|
+
}) {
|
|
26
|
+
return (
|
|
27
|
+
<DialogShell
|
|
28
|
+
kind="drawer"
|
|
29
|
+
open={open}
|
|
30
|
+
title={title}
|
|
31
|
+
label={label}
|
|
32
|
+
side={side}
|
|
33
|
+
dismissible={dismissible}
|
|
34
|
+
closeLabel={closeLabel}
|
|
35
|
+
onOpenChange={onOpenChange}
|
|
36
|
+
onClose={onClose}
|
|
37
|
+
header={header}
|
|
38
|
+
footer={footer}>
|
|
39
|
+
{children}
|
|
40
|
+
</DialogShell>
|
|
41
|
+
)
|
|
42
|
+
}
|