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