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
|
@@ -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(' ')
|