innoboxrr-react-form-elements 3.3.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 CHANGED
@@ -1,12 +1,15 @@
1
1
  # innoboxrr-react-form-elements
2
2
 
3
- Gemelo React de [`innoboxrr-form-elements`](../form-elements). Los mismos 29
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 (`tests/parity.test.jsx`) que falla
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.3.0",
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.3.0",
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: texto que se convierte en input al
5
- * hacer clic. `onInput` recibe el valor confirmado.
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({ value = '', onInput, customClass = 'fe-input' }) {
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 [draft, setDraft] = useState(value)
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
- setDraft(value)
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 confirm = () => {
23
- setEditing(false)
24
- onInput?.(draft)
54
+ const start = () => {
55
+ setDraft(shown ?? '')
56
+ setError(null)
57
+ setEditingState(true)
25
58
  }
26
59
 
27
60
  const cancel = () => {
28
- setDraft(value)
29
- setEditing(false)
61
+ setEditingState(false)
62
+ setError(null)
30
63
  }
31
64
 
32
- if (! editing) {
33
- return (
34
- <span
35
- role="button"
36
- tabIndex={0}
37
- onClick={() => setEditing(true)}
38
- onKeyDown={(event) => {
39
- if (event.key === 'Enter' || event.key === ' ') {
40
- setEditing(true)
41
- }
42
- }}>
43
- {value}
44
- </span>
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
- <input
50
- ref={input}
51
- className={customClass}
52
- value={draft}
53
- onChange={(event) => setDraft(event.target.value)}
54
- onBlur={confirm}
55
- onKeyDown={(event) => {
56
- if (event.key === 'Enter') {
57
- confirm()
58
- }
59
-
60
- if (event.key === 'Escape') {
61
- cancel()
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
+ }
@@ -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
+ }
@@ -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(' ')