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 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.5.0",
4
+ "description": "Gemelo React de innoboxrr-form-elements: los mismos componentes, los mismos nombres y el mismo contrato, con diálogos, drawers, menús, avisos y paleta de comandos.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -48,11 +48,12 @@
48
48
  "@dnd-kit/core": "^6.3.1",
49
49
  "@dnd-kit/sortable": "^10.0.0",
50
50
  "@dnd-kit/utilities": "^3.2.2",
51
+ "@floating-ui/dom": "^1.8.0",
51
52
  "@iconify/react": "^6.0.0",
52
53
  "@tinymce/tinymce-react": "^6.3.0",
53
54
  "@uiw/react-codemirror": "^4.25.11",
54
55
  "@yaireo/tagify": "^4.38.0",
55
- "innoboxrr-form-core": "^2.3.0",
56
+ "innoboxrr-form-core": "^2.7.0",
56
57
  "innoboxrr-maskjs": "^2.0.0",
57
58
  "react-colorful": "^5.8.1",
58
59
  "react-phone-number-input": "^3.4.18",
@@ -21,13 +21,14 @@ export default function CheckboxInputComponent({
21
21
  }) {
22
22
  const [current, set] = useControlled(value, onChange, '')
23
23
  const boxClass = useThemeClass('checkbox', customClass)
24
+ const labelClass = useThemeClass('label')
24
25
 
25
26
  const isGroup = Array.isArray(current)
26
27
  const checked = isGroup ? current.includes(val) : Boolean(current)
27
28
 
28
29
  return (
29
30
  <div className="fe-mb">
30
- <label className="ml-2 text-sm font-medium text-gray-900 dark:text-white">
31
+ <label className={labelClass}>
31
32
  <input
32
33
  className={boxClass}
33
34
  type="checkbox"
@@ -1,65 +1,140 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
+ import useTheme from './internal/useTheme.js'
2
3
 
3
4
  /**
4
- * Gemelo de ClickToEditComponent.vue: 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
  }
@@ -1,5 +1,6 @@
1
1
  import { useRef, useState } from 'react'
2
2
  import useControlled from './internal/useControlled.js'
3
+ import useTheme, { joinClasses } from './internal/useTheme.js'
3
4
 
4
5
  /**
5
6
  * Gemelo de CodeInputComponent.vue: las casillas de un código de un solo uso.
@@ -20,6 +21,7 @@ export default function CodeInputComponent({
20
21
  onChange,
21
22
  onComplete,
22
23
  }) {
24
+ const theme = useTheme()
23
25
  const [code, setCode] = useControlled(value, onChange, '')
24
26
  const inputs = useRef([])
25
27
  const [chars, setChars] = useState(() => Array.from({ length: fields }, (_, i) => (code ?? '')[i] ?? ''))
@@ -45,12 +47,12 @@ export default function CodeInputComponent({
45
47
  <div className={['code-input-container', className].filter(Boolean).join(' ')}>
46
48
  {title ? <p className="title">{title}</p> : null}
47
49
 
48
- <div className="code-input fe-code-input">
50
+ <div className={joinClasses('code-input', theme.codeInput)}>
49
51
  {chars.map((char, index) => (
50
52
  <input
51
53
  key={index}
52
54
  ref={(element) => { inputs.current[index] = element }}
53
- className="w-14 h-14 rounded-lg border border-gray outline-none focus:outline-none focus:border-primary focus:ring-0 text-center transition-all"
55
+ className={theme.codeCell}
54
56
  type="text"
55
57
  inputMode="numeric"
56
58
  pattern="[0-9]*"
@@ -0,0 +1,182 @@
1
+ import { useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import IconComponent from './IconComponent.jsx'
3
+ import useNativeDialog from './internal/useNativeDialog.js'
4
+ import useTheme, { joinClasses } from './internal/useTheme.js'
5
+
6
+ const normalize = (value) => String(value ?? '')
7
+ .normalize('NFD')
8
+ .replace(/[̀-ͯ]/g, '')
9
+ .toLowerCase()
10
+
11
+ /**
12
+ * Gemelo de CommandPaletteComponent.vue: buscar y ejecutar cualquier cosa sin
13
+ * tocar el ratón.
14
+ *
15
+ * <CommandPaletteComponent open={abierta} onOpenChange={setAbierta} items={[
16
+ * { id: 'products', label: 'Productos', group: 'Ir a', icon: 'box', action: () => navigate(…) },
17
+ * ]} onSelect={…} />
18
+ *
19
+ * Ctrl+K o Cmd+K la abren y la cierran; `hotkey` cambia la tecla y `null` quita
20
+ * el atajo. El filtro no distingue mayúsculas ni acentos.
21
+ */
22
+ export default function CommandPaletteComponent({
23
+ open = false,
24
+ items = [],
25
+ placeholder = 'Buscar…',
26
+ emptyText = 'Sin resultados',
27
+ label = 'Paleta de comandos',
28
+ hotkey = 'k',
29
+ onOpenChange,
30
+ onSelect,
31
+ }) {
32
+ const theme = useTheme()
33
+ const listId = useId()
34
+ const input = useRef(null)
35
+ const [query, setQuery] = useState('')
36
+ const [active, setActive] = useState(0)
37
+
38
+ const latest = useRef({ open, hotkey, onOpenChange })
39
+
40
+ latest.current = { open, hotkey, onOpenChange }
41
+
42
+ const requestClose = () => onOpenChange?.(false)
43
+
44
+ const { ref, onClick } = useNativeDialog({ open, dismissible: true, onRequestClose: requestClose })
45
+
46
+ const filtered = useMemo(() => {
47
+ const needle = normalize(query).trim()
48
+
49
+ if (needle === '') {
50
+ return items
51
+ }
52
+
53
+ return items.filter((item) => normalize([item.label, item.group, ...(item.keywords ?? [])].join(' ')).includes(needle))
54
+ }, [items, query])
55
+
56
+ const sections = useMemo(() => {
57
+ const groups = new Map()
58
+
59
+ filtered.forEach((item, index) => {
60
+ const key = item.group ?? null
61
+
62
+ if (! groups.has(key)) {
63
+ groups.set(key, [])
64
+ }
65
+
66
+ groups.get(key).push({ item, index })
67
+ })
68
+
69
+ return [...groups].map(([group, entries]) => ({ group, entries }))
70
+ }, [filtered])
71
+
72
+ useEffect(() => {
73
+ if (open) {
74
+ setQuery('')
75
+ setActive(0)
76
+ input.current?.focus()
77
+ }
78
+ }, [open])
79
+
80
+ useEffect(() => {
81
+ const onHotkey = (event) => {
82
+ const { hotkey: key, open: isOpen, onOpenChange: change } = latest.current
83
+
84
+ if (key && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === key.toLowerCase()) {
85
+ event.preventDefault()
86
+ change?.(! isOpen)
87
+ }
88
+ }
89
+
90
+ window.addEventListener('keydown', onHotkey)
91
+
92
+ return () => window.removeEventListener('keydown', onHotkey)
93
+ }, [])
94
+
95
+ const optionId = (index) => `${listId}-${index}`
96
+
97
+ const choose = (item) => {
98
+ if (! item) {
99
+ return
100
+ }
101
+
102
+ requestClose()
103
+ onSelect?.(item)
104
+ item.action?.(item)
105
+ }
106
+
107
+ const onKeyDown = (event) => {
108
+ const total = filtered.length
109
+
110
+ if (event.key === 'ArrowDown' && total > 0) {
111
+ event.preventDefault()
112
+ setActive((current) => (current + 1) % total)
113
+ }
114
+
115
+ if (event.key === 'ArrowUp' && total > 0) {
116
+ event.preventDefault()
117
+ setActive((current) => (current - 1 + total) % total)
118
+ }
119
+
120
+ if (event.key === 'Enter') {
121
+ event.preventDefault()
122
+ choose(filtered[active])
123
+ }
124
+ }
125
+
126
+ return (
127
+ <dialog
128
+ ref={ref}
129
+ className={joinClasses(theme.dialog, theme.command)}
130
+ aria-label={label}
131
+ onClick={onClick}>
132
+
133
+ {open ? (
134
+ <>
135
+ <input
136
+ ref={input}
137
+ type="text"
138
+ className={theme.commandInput}
139
+ placeholder={placeholder}
140
+ value={query}
141
+ role="combobox"
142
+ aria-autocomplete="list"
143
+ aria-expanded="true"
144
+ aria-controls={listId}
145
+ aria-activedescendant={filtered.length > 0 ? optionId(active) : undefined}
146
+ onChange={(event) => {
147
+ setQuery(event.target.value)
148
+ setActive(0)
149
+ }}
150
+ onKeyDown={onKeyDown} />
151
+
152
+ {filtered.length > 0 ? (
153
+ <ul id={listId} className={theme.commandList} role="listbox" aria-label={label}>
154
+ {sections.map((section) => [
155
+ section.group
156
+ ? <li key={`group-${section.group}`} role="presentation" className={theme.commandGroup}>{section.group}</li>
157
+ : null,
158
+ ...section.entries.map((entry) => (
159
+ <li
160
+ key={entry.item.id ?? entry.index}
161
+ id={optionId(entry.index)}
162
+ role="option"
163
+ aria-selected={entry.index === active ? 'true' : 'false'}
164
+ className={theme.commandItem}
165
+ onClick={() => choose(entry.item)}
166
+ onMouseMove={() => setActive(entry.index)}>
167
+ {entry.item.icon ? <IconComponent name={entry.item.icon} size={16} /> : null}
168
+ <span>{entry.item.label}</span>
169
+ {entry.item.shortcut ? <kbd className={theme.kbd}>{entry.item.shortcut}</kbd> : null}
170
+ </li>
171
+ )),
172
+ ])}
173
+ </ul>
174
+ ) : (
175
+ <p className={theme.commandEmpty}>{emptyText}</p>
176
+ )}
177
+ </>
178
+ ) : null}
179
+
180
+ </dialog>
181
+ )
182
+ }
@@ -0,0 +1,50 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import { getConfirmation, onConfirmationChange, resolveConfirmation } from 'innoboxrr-form-core'
3
+ import DialogShell from './internal/DialogShell.jsx'
4
+ import useTheme from './internal/useTheme.js'
5
+
6
+ /**
7
+ * Gemelo de ConfirmHostComponent.vue: donde se pregunta lo que pide
8
+ * `confirmAction()`. Se monta una vez, en la raíz de la aplicación.
9
+ *
10
+ * Cerrar con Escape o con un clic fuera cuenta como cancelar, y el foco empieza
11
+ * en cancelar: un Enter por inercia no debe borrar nada.
12
+ */
13
+ export default function ConfirmHostComponent({ closeLabel = 'Cerrar' }) {
14
+ const confirmation = useSyncExternalStore(onConfirmationChange, getConfirmation, getConfirmation)
15
+ const theme = useTheme()
16
+
17
+ return (
18
+ <DialogShell
19
+ kind="dialog"
20
+ size="sm"
21
+ open={confirmation !== null}
22
+ title={confirmation?.title ?? null}
23
+ closeLabel={closeLabel}
24
+ onOpenChange={(value) => {
25
+ if (! value) {
26
+ resolveConfirmation(false)
27
+ }
28
+ }}
29
+ footer={(
30
+ <>
31
+ <button
32
+ type="button"
33
+ className={theme.buttonSecondary}
34
+ data-autofocus=""
35
+ onClick={() => resolveConfirmation(false)}>
36
+ {confirmation?.cancelLabel}
37
+ </button>
38
+
39
+ <button
40
+ type="button"
41
+ className={confirmation?.variant === 'danger' ? theme.buttonDanger : theme.button}
42
+ onClick={() => resolveConfirmation(true)}>
43
+ {confirmation?.confirmLabel}
44
+ </button>
45
+ </>
46
+ )}>
47
+ <p>{confirmation?.message}</p>
48
+ </DialogShell>
49
+ )
50
+ }
@@ -80,7 +80,7 @@ export default function CountrySelectInputComponent({
80
80
  }}
81
81
  {...rest} />
82
82
 
83
- {help ? <p className="text-sm text-gray-500">{help}</p> : null}
83
+ {help ? <p className="fe-text-muted">{help}</p> : null}
84
84
  </div>
85
85
  </div>
86
86
  )
@@ -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
+ }