innoboxrr-react-form-elements 3.2.0 → 3.4.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/ClickToEditComponent.jsx +116 -41
- package/src/CommandPaletteComponent.jsx +182 -0
- package/src/ConfirmHostComponent.jsx +50 -0
- package/src/DialogComponent.jsx +43 -0
- package/src/DrawerComponent.jsx +42 -0
- package/src/DynamicGroupInputComponent.jsx +5 -4
- package/src/MenuComponent.jsx +252 -0
- package/src/SkeletonComponent.jsx +36 -0
- package/src/TextInputComponent.jsx +1 -1
- 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.4.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.5.0",
|
|
56
57
|
"innoboxrr-maskjs": "^2.0.0",
|
|
57
58
|
"react-colorful": "^5.8.1",
|
|
58
59
|
"react-phone-number-input": "^3.4.18",
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useMemo, useRef } from 'react'
|
|
2
|
+
import IconComponent from './IconComponent.jsx'
|
|
2
3
|
import {
|
|
3
4
|
DndContext,
|
|
4
5
|
KeyboardSensor,
|
|
@@ -132,7 +133,7 @@ export default function DynamicGroupInputComponent({
|
|
|
132
133
|
className="cursor-move drag-handle text-slate-400"
|
|
133
134
|
{...attributes}
|
|
134
135
|
{...listeners}>
|
|
135
|
-
<
|
|
136
|
+
<IconComponent name="drag" />
|
|
136
137
|
</button>
|
|
137
138
|
<h4 className="text-md font-semibold text-slate-800 dark:text-slate-100">
|
|
138
139
|
{itemLabel} #{index + 1}
|
|
@@ -149,7 +150,7 @@ export default function DynamicGroupInputComponent({
|
|
|
149
150
|
{ ...group, __key: `grupo-${nextKey.current++}` },
|
|
150
151
|
...keyed.slice(index + 1),
|
|
151
152
|
])}>
|
|
152
|
-
<
|
|
153
|
+
<IconComponent name="copy" />
|
|
153
154
|
</button>
|
|
154
155
|
|
|
155
156
|
<button
|
|
@@ -157,7 +158,7 @@ export default function DynamicGroupInputComponent({
|
|
|
157
158
|
aria-label={`${removeButtonLabel} ${index + 1}`}
|
|
158
159
|
className="text-red-800 dark:text-red-400 text-sm"
|
|
159
160
|
onClick={() => set(keyed.filter((_, position) => position !== index))}>
|
|
160
|
-
<
|
|
161
|
+
<IconComponent name="delete" />
|
|
161
162
|
</button>
|
|
162
163
|
|
|
163
164
|
<button
|
|
@@ -166,7 +167,7 @@ export default function DynamicGroupInputComponent({
|
|
|
166
167
|
aria-expanded={! group._collapsed}
|
|
167
168
|
className="hover:text-slate-600 dark:hover:text-slate-300 transition"
|
|
168
169
|
onClick={() => updateAt(index, '_collapsed', ! group._collapsed)}>
|
|
169
|
-
<
|
|
170
|
+
<IconComponent name={group._collapsed ? 'up' : 'down'} />
|
|
170
171
|
</button>
|
|
171
172
|
</div>
|
|
172
173
|
</div>
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from 'react'
|
|
2
|
+
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
|
|
3
|
+
import IconComponent from './IconComponent.jsx'
|
|
4
|
+
import useTheme, { joinClasses } from './internal/useTheme.js'
|
|
5
|
+
|
|
6
|
+
const supportsPopover = typeof HTMLElement !== 'undefined'
|
|
7
|
+
&& typeof HTMLElement.prototype.showPopover === 'function'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Gemelo de MenuComponent.vue: un menú desplegable sobre el atributo popover y
|
|
11
|
+
* Floating UI.
|
|
12
|
+
*
|
|
13
|
+
* <MenuComponent items={[
|
|
14
|
+
* { id: 'edit', label: 'Editar', icon: 'edit', action: editar },
|
|
15
|
+
* { separator: true },
|
|
16
|
+
* { id: 'delete', label: 'Eliminar', danger: true, disabled: ! puede, disabledReason: 'Sin permiso' },
|
|
17
|
+
* ]} onSelect={…} />
|
|
18
|
+
*
|
|
19
|
+
* `beforeOpen` se espera antes de abrir, para los permisos de una fila.
|
|
20
|
+
* `renderTrigger({ toggle, open, loading, triggerProps })` sustituye al botón,
|
|
21
|
+
* como el slot `trigger` de Vue.
|
|
22
|
+
*/
|
|
23
|
+
export default function MenuComponent({
|
|
24
|
+
items = [],
|
|
25
|
+
label = 'Acciones',
|
|
26
|
+
icon = 'more',
|
|
27
|
+
placement = 'bottom-end',
|
|
28
|
+
beforeOpen = null,
|
|
29
|
+
onSelect,
|
|
30
|
+
onOpen,
|
|
31
|
+
onClose,
|
|
32
|
+
renderTrigger = null,
|
|
33
|
+
}) {
|
|
34
|
+
const theme = useTheme()
|
|
35
|
+
const menuId = useId()
|
|
36
|
+
const menu = useRef(null)
|
|
37
|
+
const trigger = useRef(null)
|
|
38
|
+
const reference = useRef(null)
|
|
39
|
+
const [open, setOpen] = useState(false)
|
|
40
|
+
const [loading, setLoading] = useState(false)
|
|
41
|
+
|
|
42
|
+
const latest = useRef({ onOpen, onClose, placement })
|
|
43
|
+
|
|
44
|
+
latest.current = { onOpen, onClose, placement }
|
|
45
|
+
|
|
46
|
+
const enabledItems = () => [...(menu.current?.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])') ?? [])]
|
|
47
|
+
|
|
48
|
+
const focusItem = (index) => {
|
|
49
|
+
const list = enabledItems()
|
|
50
|
+
|
|
51
|
+
if (list.length > 0) {
|
|
52
|
+
list[(index + list.length) % list.length].focus()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const opened = () => {
|
|
57
|
+
setOpen(true)
|
|
58
|
+
latest.current.onOpen?.()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const closed = () => {
|
|
62
|
+
const hadFocus = menu.current?.contains(document.activeElement) || document.activeElement === document.body
|
|
63
|
+
|
|
64
|
+
setOpen(false)
|
|
65
|
+
latest.current.onClose?.()
|
|
66
|
+
|
|
67
|
+
if (hadFocus) {
|
|
68
|
+
reference.current?.focus?.()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const element = menu.current
|
|
74
|
+
|
|
75
|
+
if (! element) {
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const onToggle = (event) => (event.newState === 'open' ? opened() : closed())
|
|
80
|
+
|
|
81
|
+
element.addEventListener('toggle', onToggle)
|
|
82
|
+
|
|
83
|
+
return () => element.removeEventListener('toggle', onToggle)
|
|
84
|
+
}, [])
|
|
85
|
+
|
|
86
|
+
// Colocar y enfocar una vez que el menú abierto ya está pintado.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const element = menu.current
|
|
89
|
+
|
|
90
|
+
if (! open || ! element) {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let stop = null
|
|
95
|
+
|
|
96
|
+
if (reference.current) {
|
|
97
|
+
const update = () => computePosition(reference.current, element, {
|
|
98
|
+
placement: latest.current.placement,
|
|
99
|
+
strategy: 'fixed',
|
|
100
|
+
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
|
101
|
+
}).then(({ x, y }) => {
|
|
102
|
+
Object.assign(element.style, { left: `${x}px`, top: `${y}px` })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
stop = autoUpdate(reference.current, element, update)
|
|
107
|
+
} catch {
|
|
108
|
+
update()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
focusItem(0)
|
|
113
|
+
|
|
114
|
+
return () => stop?.()
|
|
115
|
+
}, [open])
|
|
116
|
+
|
|
117
|
+
const show = () => {
|
|
118
|
+
if (supportsPopover) {
|
|
119
|
+
menu.current?.showPopover()
|
|
120
|
+
} else {
|
|
121
|
+
opened()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const hide = () => {
|
|
126
|
+
if (supportsPopover) {
|
|
127
|
+
try {
|
|
128
|
+
menu.current?.hidePopover()
|
|
129
|
+
} catch {
|
|
130
|
+
// Ya estaba cerrado.
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
closed()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const toggle = async (event) => {
|
|
138
|
+
if (open) {
|
|
139
|
+
hide()
|
|
140
|
+
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
reference.current = event?.currentTarget ?? trigger.current
|
|
145
|
+
|
|
146
|
+
if (beforeOpen) {
|
|
147
|
+
setLoading(true)
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await beforeOpen()
|
|
151
|
+
} finally {
|
|
152
|
+
setLoading(false)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
show()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const choose = (item) => {
|
|
160
|
+
if (item.disabled) {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
hide()
|
|
165
|
+
onSelect?.(item)
|
|
166
|
+
item.action?.(item)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const onKeyDown = (event) => {
|
|
170
|
+
const list = enabledItems()
|
|
171
|
+
const current = list.indexOf(document.activeElement)
|
|
172
|
+
|
|
173
|
+
const moves = {
|
|
174
|
+
ArrowDown: current + 1,
|
|
175
|
+
ArrowUp: current - 1,
|
|
176
|
+
Home: 0,
|
|
177
|
+
End: list.length - 1,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (event.key in moves) {
|
|
181
|
+
event.preventDefault()
|
|
182
|
+
focusItem(moves[event.key])
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (event.key === 'Escape' && ! supportsPopover) {
|
|
186
|
+
hide()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const triggerProps = {
|
|
191
|
+
'aria-haspopup': 'menu',
|
|
192
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
193
|
+
'aria-controls': menuId,
|
|
194
|
+
'aria-label': label,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<span className="fe-inline">
|
|
199
|
+
|
|
200
|
+
{renderTrigger ? renderTrigger({ toggle, open, loading, triggerProps }) : (
|
|
201
|
+
<button
|
|
202
|
+
ref={trigger}
|
|
203
|
+
type="button"
|
|
204
|
+
className={theme.iconButton}
|
|
205
|
+
disabled={loading}
|
|
206
|
+
{...triggerProps}
|
|
207
|
+
onClick={toggle}>
|
|
208
|
+
<IconComponent name={icon} size={16} />
|
|
209
|
+
</button>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
<div
|
|
213
|
+
id={menuId}
|
|
214
|
+
ref={menu}
|
|
215
|
+
popover="auto"
|
|
216
|
+
className={theme.menu}
|
|
217
|
+
hidden={! supportsPopover && ! open ? true : undefined}
|
|
218
|
+
onKeyDown={onKeyDown}>
|
|
219
|
+
|
|
220
|
+
<ul className={theme.menuList} role="menu" aria-label={label}>
|
|
221
|
+
{items.map((item, index) => {
|
|
222
|
+
if (item.separator) {
|
|
223
|
+
return <li key={item.id ?? `separator-${index}`} role="separator" className={theme.menuSeparator} />
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (item.group) {
|
|
227
|
+
return <li key={item.id ?? `group-${index}`} role="presentation" className={theme.menuLabel}>{item.group}</li>
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<li key={item.id ?? index} role="none">
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
role="menuitem"
|
|
235
|
+
className={joinClasses(theme.menuItem, item.danger && theme.menuItemDanger)}
|
|
236
|
+
aria-disabled={item.disabled ? 'true' : undefined}
|
|
237
|
+
data-tooltip={item.disabled && item.disabledReason ? item.disabledReason : undefined}
|
|
238
|
+
onClick={() => choose(item)}>
|
|
239
|
+
{item.icon ? <IconComponent name={item.icon} size={14} /> : null}
|
|
240
|
+
<span>{item.label}</span>
|
|
241
|
+
{item.shortcut ? <kbd className={theme.kbd}>{item.shortcut}</kbd> : null}
|
|
242
|
+
</button>
|
|
243
|
+
</li>
|
|
244
|
+
)
|
|
245
|
+
})}
|
|
246
|
+
</ul>
|
|
247
|
+
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
</span>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import useTheme, { joinClasses } from './internal/useTheme.js'
|
|
2
|
+
|
|
3
|
+
const TOKENS = { text: 'skeletonText', circle: 'skeletonCircle', block: 'skeletonBlock' }
|
|
4
|
+
|
|
5
|
+
const size = (value) => (typeof value === 'number' ? `${value}px` : value)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Gemelo de SkeletonComponent.vue: la forma de lo que va a llegar, mientras
|
|
9
|
+
* llega.
|
|
10
|
+
*
|
|
11
|
+
* <SkeletonComponent lines={3} />
|
|
12
|
+
* <SkeletonComponent shape="circle" width="3rem" />
|
|
13
|
+
*/
|
|
14
|
+
export default function SkeletonComponent({ shape = 'text', lines = 1, width = null, height = null }) {
|
|
15
|
+
const theme = useTheme()
|
|
16
|
+
|
|
17
|
+
const count = shape === 'text' ? Math.max(1, lines) : 1
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div aria-hidden="true" data-shape={shape}>
|
|
21
|
+
{Array.from({ length: count }, (_, index) => {
|
|
22
|
+
const last = shape === 'text' && count > 1 && index === count - 1
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<span
|
|
26
|
+
key={index}
|
|
27
|
+
className={joinClasses(theme.skeleton, theme[TOKENS[shape] ?? 'skeletonText'])}
|
|
28
|
+
style={{
|
|
29
|
+
width: last ? '60%' : (size(width) ?? undefined),
|
|
30
|
+
height: size(height) ?? undefined,
|
|
31
|
+
}} />
|
|
32
|
+
)
|
|
33
|
+
})}
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -109,7 +109,7 @@ export default function TextInputComponent({
|
|
|
109
109
|
className="fe-password-toggle"
|
|
110
110
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
111
111
|
onClick={() => setShowPassword((shown) => ! shown)}>
|
|
112
|
-
<
|
|
112
|
+
<IconComponent name={showPassword ? 'hide' : 'show'} />
|
|
113
113
|
</button>
|
|
114
114
|
) : null}
|
|
115
115
|
</div>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useEffect, useRef, useSyncExternalStore } from 'react'
|
|
2
|
+
import { dismiss, getToasts, onToastsChange } from 'innoboxrr-form-core'
|
|
3
|
+
import IconComponent from './IconComponent.jsx'
|
|
4
|
+
import useTheme, { joinClasses } from './internal/useTheme.js'
|
|
5
|
+
|
|
6
|
+
const supportsPopover = typeof HTMLElement !== 'undefined'
|
|
7
|
+
&& typeof HTMLElement.prototype.showPopover === 'function'
|
|
8
|
+
|
|
9
|
+
const isShown = (element) => {
|
|
10
|
+
try {
|
|
11
|
+
return element.matches(':popover-open')
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Gemelo de ToastRegionComponent.vue: donde aparecen los avisos de `notify()`.
|
|
19
|
+
* Se monta una vez, en la raíz de la aplicación.
|
|
20
|
+
*
|
|
21
|
+
* Va en la capa superior con popover="manual", para no quedar debajo de un
|
|
22
|
+
* drawer abierto con showModal().
|
|
23
|
+
*/
|
|
24
|
+
export default function ToastRegionComponent({ label = 'Avisos', closeLabel = 'Cerrar' }) {
|
|
25
|
+
const toasts = useSyncExternalStore(onToastsChange, getToasts, getToasts)
|
|
26
|
+
const theme = useTheme()
|
|
27
|
+
const region = useRef(null)
|
|
28
|
+
const previous = useRef(0)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const element = region.current
|
|
32
|
+
const count = toasts.length
|
|
33
|
+
const before = previous.current
|
|
34
|
+
|
|
35
|
+
previous.current = count
|
|
36
|
+
|
|
37
|
+
if (! element || ! supportsPopover) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (count === 0) {
|
|
42
|
+
if (isShown(element)) {
|
|
43
|
+
element.hidePopover()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Un diálogo abierto después que la región queda por encima; volver a
|
|
50
|
+
// mostrarla la sube. Solo entonces, porque reinicia la animación.
|
|
51
|
+
const modalOpen = document.querySelector('dialog[open]') !== null
|
|
52
|
+
|
|
53
|
+
if (isShown(element) && count > before && modalOpen) {
|
|
54
|
+
element.hidePopover()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (! isShown(element)) {
|
|
58
|
+
element.showPopover()
|
|
59
|
+
}
|
|
60
|
+
}, [toasts])
|
|
61
|
+
|
|
62
|
+
const variants = {
|
|
63
|
+
success: theme.toastSuccess,
|
|
64
|
+
danger: theme.toastDanger,
|
|
65
|
+
warning: theme.toastWarning,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
ref={region}
|
|
71
|
+
className={theme.toastRegion}
|
|
72
|
+
popover="manual"
|
|
73
|
+
role="region"
|
|
74
|
+
aria-label={label}
|
|
75
|
+
hidden={! supportsPopover && toasts.length === 0 ? true : undefined}>
|
|
76
|
+
|
|
77
|
+
{toasts.map((toast) => (
|
|
78
|
+
<div
|
|
79
|
+
key={toast.id}
|
|
80
|
+
className={joinClasses(theme.toast, variants[toast.variant])}
|
|
81
|
+
role={toast.variant === 'danger' ? 'alert' : 'status'}
|
|
82
|
+
data-variant={toast.variant}>
|
|
83
|
+
|
|
84
|
+
<div>
|
|
85
|
+
{toast.title ? <strong className={theme.toastTitle}>{toast.title}</strong> : null}
|
|
86
|
+
<span>{toast.message}</span>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
className={joinClasses(theme.iconButton, theme.toastClose)}
|
|
92
|
+
aria-label={closeLabel}
|
|
93
|
+
onClick={() => dismiss(toast.id)}>
|
|
94
|
+
<IconComponent name="close" size={14} />
|
|
95
|
+
</button>
|
|
96
|
+
|
|
97
|
+
</div>
|
|
98
|
+
))}
|
|
99
|
+
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useId } from 'react'
|
|
2
|
+
import IconComponent from '../IconComponent.jsx'
|
|
3
|
+
import useNativeDialog from './useNativeDialog.js'
|
|
4
|
+
import useTheme, { joinClasses } from './useTheme.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Gemelo de internal/DialogShell.vue: lo común a DialogComponent y
|
|
8
|
+
* DrawerComponent.
|
|
9
|
+
*
|
|
10
|
+
* `header` y `footer` son nodos, o funciones que reciben `{ close }`, igual que
|
|
11
|
+
* los slots con ámbito de Vue.
|
|
12
|
+
*/
|
|
13
|
+
export default function DialogShell({
|
|
14
|
+
kind = 'dialog',
|
|
15
|
+
open = false,
|
|
16
|
+
title = null,
|
|
17
|
+
label = null,
|
|
18
|
+
size = 'md',
|
|
19
|
+
side = 'end',
|
|
20
|
+
dismissible = true,
|
|
21
|
+
closeLabel = 'Cerrar',
|
|
22
|
+
onOpenChange,
|
|
23
|
+
onClose,
|
|
24
|
+
header = null,
|
|
25
|
+
footer = null,
|
|
26
|
+
children,
|
|
27
|
+
}) {
|
|
28
|
+
const theme = useTheme()
|
|
29
|
+
const titleId = useId()
|
|
30
|
+
|
|
31
|
+
const part = kind === 'drawer' ? 'drawer' : 'dialog'
|
|
32
|
+
|
|
33
|
+
const modifier = kind === 'drawer'
|
|
34
|
+
? (side === 'start' ? 'drawerStart' : null)
|
|
35
|
+
: ({ sm: 'dialogSmall', lg: 'dialogLarge' }[size] ?? null)
|
|
36
|
+
|
|
37
|
+
const requestClose = () => {
|
|
38
|
+
onOpenChange?.(false)
|
|
39
|
+
onClose?.()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { ref, onClick } = useNativeDialog({ open, dismissible, onRequestClose: requestClose })
|
|
43
|
+
|
|
44
|
+
const render = (slot) => (typeof slot === 'function' ? slot({ close: requestClose }) : slot)
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<dialog
|
|
48
|
+
ref={ref}
|
|
49
|
+
className={joinClasses(theme[part], modifier && theme[modifier])}
|
|
50
|
+
aria-labelledby={title ? titleId : undefined}
|
|
51
|
+
aria-label={title ? undefined : (label ?? undefined)}
|
|
52
|
+
onClick={onClick}>
|
|
53
|
+
|
|
54
|
+
{/* El contenido existe solo mientras está abierto: un formulario
|
|
55
|
+
vuelve limpio cada vez. */}
|
|
56
|
+
{open ? (
|
|
57
|
+
<>
|
|
58
|
+
{(title || header || dismissible) ? (
|
|
59
|
+
<header className={theme[`${part}Header`]}>
|
|
60
|
+
{header
|
|
61
|
+
? render(header)
|
|
62
|
+
: (title ? <h2 id={titleId} className={theme[`${part}Title`]}>{title}</h2> : null)}
|
|
63
|
+
|
|
64
|
+
{dismissible ? (
|
|
65
|
+
<button type="button" className={theme.iconButton} aria-label={closeLabel} onClick={requestClose}>
|
|
66
|
+
<IconComponent name="close" size={16} />
|
|
67
|
+
</button>
|
|
68
|
+
) : null}
|
|
69
|
+
</header>
|
|
70
|
+
) : null}
|
|
71
|
+
|
|
72
|
+
<div className={theme[`${part}Body`]}>{render(children)}</div>
|
|
73
|
+
|
|
74
|
+
{footer ? <footer className={theme[`${part}Footer`]}>{render(footer)}</footer> : null}
|
|
75
|
+
</>
|
|
76
|
+
) : null}
|
|
77
|
+
|
|
78
|
+
</dialog>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemelo de composables/useNativeDialog.js de la rama Vue: abre y cierra un
|
|
5
|
+
* <dialog> desde un booleano.
|
|
6
|
+
*
|
|
7
|
+
* El elemento del navegador pone la capa superior, el fondo inerte, el foco
|
|
8
|
+
* atrapado, Escape y la devolución del foco; pero guarda su propio estado. Aquí
|
|
9
|
+
* manda el booleano: Escape, un clic fuera o un cierre nativo solo piden cerrar.
|
|
10
|
+
*
|
|
11
|
+
* React no escribe el atributo `autofocus` en el DOM —lo resuelve él, antes de
|
|
12
|
+
* que el diálogo se abra—, así que el elemento que tiene que recibir el foco se
|
|
13
|
+
* marca con `data-autofocus` y se enfoca después de abrir.
|
|
14
|
+
*
|
|
15
|
+
* @param {{ open: boolean, dismissible: boolean, onRequestClose: () => void }} options
|
|
16
|
+
*/
|
|
17
|
+
export default function useNativeDialog({ open, dismissible, onRequestClose }) {
|
|
18
|
+
const ref = useRef(null)
|
|
19
|
+
const latest = useRef({ open, dismissible, onRequestClose })
|
|
20
|
+
|
|
21
|
+
latest.current = { open, dismissible, onRequestClose }
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const element = ref.current
|
|
25
|
+
|
|
26
|
+
if (! element) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isOpen = element.hasAttribute('open')
|
|
31
|
+
|
|
32
|
+
if (open && ! isOpen) {
|
|
33
|
+
typeof element.showModal === 'function' ? element.showModal() : element.setAttribute('open', '')
|
|
34
|
+
|
|
35
|
+
element.querySelector('[data-autofocus]')?.focus()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (! open && isOpen) {
|
|
39
|
+
typeof element.close === 'function' ? element.close() : element.removeAttribute('open')
|
|
40
|
+
}
|
|
41
|
+
}, [open])
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const element = ref.current
|
|
45
|
+
|
|
46
|
+
if (! element) {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const onCancel = (event) => {
|
|
51
|
+
event.preventDefault()
|
|
52
|
+
|
|
53
|
+
if (latest.current.dismissible) {
|
|
54
|
+
latest.current.onRequestClose?.()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const onClose = () => {
|
|
59
|
+
if (latest.current.open) {
|
|
60
|
+
latest.current.onRequestClose?.()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
element.addEventListener('cancel', onCancel)
|
|
65
|
+
element.addEventListener('close', onClose)
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
element.removeEventListener('cancel', onCancel)
|
|
69
|
+
element.removeEventListener('close', onClose)
|
|
70
|
+
}
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Un clic en el fondo llega con el propio <dialog> como destino y fuera de
|
|
75
|
+
* su caja.
|
|
76
|
+
*/
|
|
77
|
+
const onClick = useCallback((event) => {
|
|
78
|
+
const element = ref.current
|
|
79
|
+
|
|
80
|
+
if (! element || event.target !== element || ! latest.current.dismissible) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const box = element.getBoundingClientRect()
|
|
85
|
+
|
|
86
|
+
const inside = event.clientX >= box.left
|
|
87
|
+
&& event.clientX <= box.right
|
|
88
|
+
&& event.clientY >= box.top
|
|
89
|
+
&& event.clientY <= box.bottom
|
|
90
|
+
|
|
91
|
+
if (! inside) {
|
|
92
|
+
latest.current.onRequestClose?.()
|
|
93
|
+
}
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
return { ref, onClick }
|
|
97
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import { getTheme, onThemeChange } from 'innoboxrr-form-core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* El tema completo, para los componentes que leen varios tokens a la vez.
|
|
6
|
+
*
|
|
7
|
+
* Va por `useSyncExternalStore` porque el tema es estado de módulo: un
|
|
8
|
+
* `setTheme` en caliente tiene que repintar lo ya montado.
|
|
9
|
+
*
|
|
10
|
+
* @returns {Record<string, string>}
|
|
11
|
+
*/
|
|
12
|
+
export default function useTheme() {
|
|
13
|
+
return useSyncExternalStore(onThemeChange, () => getTheme(), () => getTheme())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Une clases descartando las vacías.
|
|
18
|
+
*
|
|
19
|
+
* @param {...(string|null|undefined|false)} classes
|
|
20
|
+
*/
|
|
21
|
+
export const joinClasses = (...classes) => classes.filter(Boolean).join(' ')
|