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
|
@@ -21,6 +21,7 @@ import SelectInputComponent from './SelectInputComponent.jsx'
|
|
|
21
21
|
import TextInputComponent from './TextInputComponent.jsx'
|
|
22
22
|
import TextareaInputComponent from './TextareaInputComponent.jsx'
|
|
23
23
|
import useControlled from './internal/useControlled.js'
|
|
24
|
+
import useTheme, { joinClasses } from './internal/useTheme.js'
|
|
24
25
|
|
|
25
26
|
const COMPONENTS = {
|
|
26
27
|
text: TextInputComponent,
|
|
@@ -38,15 +39,20 @@ const COMPONENTS = {
|
|
|
38
39
|
* por su cuenta y trae ordenación **por teclado**, que ni SortableJS ni la API
|
|
39
40
|
* nativa de arrastre dan. Aquí no es un detalle: un formulario que solo se
|
|
40
41
|
* puede reordenar con el ratón no es accesible.
|
|
42
|
+
*
|
|
43
|
+
* El aspecto sale del tema, con el mismo marcado que la rama Vue: cada grupo es
|
|
44
|
+
* una superficie con su barra. Antes eran clases de Tailwind y colores escritos
|
|
45
|
+
* para el modo oscuro que el paquete no declara.
|
|
41
46
|
*/
|
|
42
47
|
function SortableGroup({ id, children }) {
|
|
48
|
+
const theme = useTheme()
|
|
43
49
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
|
|
44
50
|
|
|
45
51
|
return (
|
|
46
52
|
<div
|
|
47
53
|
ref={setNodeRef}
|
|
48
54
|
style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }}
|
|
49
|
-
className=
|
|
55
|
+
className={theme.surface}>
|
|
50
56
|
{children({ attributes, listeners })}
|
|
51
57
|
</div>
|
|
52
58
|
)
|
|
@@ -61,6 +67,7 @@ export default function DynamicGroupInputComponent({
|
|
|
61
67
|
removeButtonLabel = 'Eliminar',
|
|
62
68
|
itemLabel = 'Item',
|
|
63
69
|
}) {
|
|
70
|
+
const theme = useTheme()
|
|
64
71
|
const [current, set] = useControlled(value, onChange, [])
|
|
65
72
|
const nextKey = useRef(0)
|
|
66
73
|
|
|
@@ -113,67 +120,66 @@ export default function DynamicGroupInputComponent({
|
|
|
113
120
|
return (
|
|
114
121
|
<div>
|
|
115
122
|
{label ? (
|
|
116
|
-
<label className=
|
|
123
|
+
<label className={theme.label}>
|
|
117
124
|
{label}
|
|
118
125
|
</label>
|
|
119
126
|
) : null}
|
|
120
127
|
|
|
121
128
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
122
129
|
<SortableContext items={keyed.map((group) => group.__key)} strategy={verticalListSortingStrategy}>
|
|
123
|
-
<div className=
|
|
130
|
+
<div className={theme.group}>
|
|
124
131
|
{keyed.map((group, index) => (
|
|
125
132
|
<SortableGroup key={group.__key} id={group.__key}>
|
|
126
133
|
{({ attributes, listeners }) => (
|
|
127
134
|
<>
|
|
128
|
-
<div className=
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
</div>
|
|
135
|
+
<div className={theme.toolbar}>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
aria-label={`Mover ${itemLabel} ${index + 1}`}
|
|
139
|
+
className={joinClasses(theme.iconButton, theme.dragHandle, 'drag-handle')}
|
|
140
|
+
{...attributes}
|
|
141
|
+
{...listeners}>
|
|
142
|
+
<IconComponent name="drag" />
|
|
143
|
+
</button>
|
|
144
|
+
|
|
145
|
+
<h4 className={theme.groupTitle}>
|
|
146
|
+
{itemLabel} #{index + 1}
|
|
147
|
+
</h4>
|
|
148
|
+
|
|
149
|
+
<span className={theme.toolbarSpacer} />
|
|
150
|
+
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
aria-label={`Duplicar ${itemLabel} ${index + 1}`}
|
|
154
|
+
className={theme.iconButton}
|
|
155
|
+
onClick={() => set([
|
|
156
|
+
...keyed.slice(0, index + 1),
|
|
157
|
+
{ ...group, __key: `grupo-${nextKey.current++}` },
|
|
158
|
+
...keyed.slice(index + 1),
|
|
159
|
+
])}>
|
|
160
|
+
<IconComponent name="copy" />
|
|
161
|
+
</button>
|
|
162
|
+
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
aria-label={`${removeButtonLabel} ${index + 1}`}
|
|
166
|
+
className={joinClasses(theme.iconButton, theme.iconButtonDanger)}
|
|
167
|
+
onClick={() => set(keyed.filter((_, position) => position !== index))}>
|
|
168
|
+
<IconComponent name="delete" />
|
|
169
|
+
</button>
|
|
170
|
+
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
aria-label={`Expandir ${itemLabel} ${index + 1}`}
|
|
174
|
+
aria-expanded={! group._collapsed}
|
|
175
|
+
className={theme.iconButton}
|
|
176
|
+
onClick={() => updateAt(index, '_collapsed', ! group._collapsed)}>
|
|
177
|
+
<IconComponent name={group._collapsed ? 'up' : 'down'} />
|
|
178
|
+
</button>
|
|
173
179
|
</div>
|
|
174
180
|
|
|
175
181
|
{! group._collapsed ? (
|
|
176
|
-
<div className="
|
|
182
|
+
<div className="fe-card-body">
|
|
177
183
|
{inputsConfig.map((field) => {
|
|
178
184
|
const Component = COMPONENTS[field.type] ?? TextInputComponent
|
|
179
185
|
|
|
@@ -208,7 +214,7 @@ export default function DynamicGroupInputComponent({
|
|
|
208
214
|
|
|
209
215
|
<button
|
|
210
216
|
type="button"
|
|
211
|
-
className=
|
|
217
|
+
className={joinClasses(theme.button, 'fe-mt')}
|
|
212
218
|
onClick={() => set([...keyed, emptyGroup()])}>
|
|
213
219
|
{addButtonLabel}
|
|
214
220
|
</button>
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { useRef, useState } from 'react'
|
|
2
|
+
import IconComponent from './IconComponent.jsx'
|
|
3
|
+
import useTheme from './internal/useTheme.js'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Gemelo de FileDropInputComponent.vue. `onFilesChange` recibe un array de
|
|
5
7
|
* File, venga de arrastrar o del diálogo.
|
|
8
|
+
*
|
|
9
|
+
* `fe-file-drop` no lo definía ninguna hoja, así que la zona salía sin borde ni
|
|
10
|
+
* resalte. Ahora sale del tema, con el mismo icono y el mismo texto secundario
|
|
11
|
+
* que la rama Vue.
|
|
6
12
|
*/
|
|
7
13
|
export default function FileDropInputComponent({
|
|
8
14
|
multiple = false,
|
|
@@ -11,18 +17,20 @@ export default function FileDropInputComponent({
|
|
|
11
17
|
subText = 'o haz clic para seleccionar los archivos.',
|
|
12
18
|
onFilesChange,
|
|
13
19
|
}) {
|
|
20
|
+
const theme = useTheme()
|
|
14
21
|
const input = useRef(null)
|
|
15
22
|
const [dragging, setDragging] = useState(false)
|
|
16
23
|
|
|
17
24
|
return (
|
|
18
25
|
<div
|
|
19
|
-
className=
|
|
26
|
+
className={theme.fileDrop}
|
|
20
27
|
data-dragging={dragging}
|
|
21
28
|
role="button"
|
|
22
29
|
tabIndex={0}
|
|
23
30
|
onClick={() => input.current?.click()}
|
|
24
31
|
onKeyDown={(event) => {
|
|
25
32
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
33
|
+
event.preventDefault()
|
|
26
34
|
input.current?.click()
|
|
27
35
|
}
|
|
28
36
|
}}
|
|
@@ -36,8 +44,10 @@ export default function FileDropInputComponent({
|
|
|
36
44
|
setDragging(false)
|
|
37
45
|
onFilesChange?.(Array.from(event.dataTransfer.files))
|
|
38
46
|
}}>
|
|
47
|
+
<IconComponent name="media" size={32} />
|
|
48
|
+
|
|
39
49
|
<p>{mainText}</p>
|
|
40
|
-
<p>{subText}</p>
|
|
50
|
+
<p className={theme.fileDropHint}>{subText}</p>
|
|
41
51
|
|
|
42
52
|
<input
|
|
43
53
|
ref={input}
|
|
@@ -167,14 +167,14 @@ export default function FileInputComponent({
|
|
|
167
167
|
</ul>
|
|
168
168
|
|
|
169
169
|
{errors.length ? (
|
|
170
|
-
<ul className="fe-list
|
|
170
|
+
<ul className="fe-list fe-error">
|
|
171
171
|
{errors.map((entry, index) => (
|
|
172
172
|
<li key={`${entry.name}-error-${index}`}>{entry.name}: {entry.errors?.join(', ')}</li>
|
|
173
173
|
))}
|
|
174
174
|
</ul>
|
|
175
175
|
) : null}
|
|
176
176
|
|
|
177
|
-
{overTotal ? <p className="
|
|
177
|
+
{overTotal ? <p className="fe-error">El tamaño total supera el máximo permitido.</p> : null}
|
|
178
178
|
|
|
179
179
|
{! autoUpload && files.length ? (
|
|
180
180
|
<button type="button" className="fe-button" disabled={uploading || overTotal} onClick={upload}>
|
|
@@ -34,7 +34,7 @@ export default function FqsInputComponent({
|
|
|
34
34
|
|
|
35
35
|
return (
|
|
36
36
|
<div>
|
|
37
|
-
<h4>{text.title}</h4>
|
|
37
|
+
<h4 className="fe-group-title fe-mb-sm">{text.title}</h4>
|
|
38
38
|
|
|
39
39
|
{items.map((item, index) => (
|
|
40
40
|
<div key={index} className="fe-card fe-card-sm fe-card-body fe-mb">
|
|
@@ -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
|
+
}
|
|
@@ -17,10 +17,11 @@ export default function RadioInputComponent({
|
|
|
17
17
|
}) {
|
|
18
18
|
const [current, set] = useControlled(value, onChange, '')
|
|
19
19
|
const radioClass = useThemeClass('radio', customClass)
|
|
20
|
+
const labelClass = useThemeClass('label')
|
|
20
21
|
|
|
21
22
|
return (
|
|
22
23
|
<div className="fe-mb">
|
|
23
|
-
<label className=
|
|
24
|
+
<label className={labelClass}>
|
|
24
25
|
<input
|
|
25
26
|
className={radioClass}
|
|
26
27
|
type="radio"
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import useThemeClass from './internal/useThemeClass.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Gemelo de SingleCheckboxInputComponent.vue.
|
|
3
5
|
*
|
|
@@ -13,10 +15,11 @@ export default function SingleCheckboxInputComponent({
|
|
|
13
15
|
...rest
|
|
14
16
|
}) {
|
|
15
17
|
const htmlId = `${id}_${label}`
|
|
18
|
+
const labelClass = useThemeClass('label')
|
|
16
19
|
|
|
17
20
|
return (
|
|
18
21
|
<div className="fe-mb">
|
|
19
|
-
<label htmlFor={htmlId} className=
|
|
22
|
+
<label htmlFor={htmlId} className={labelClass}>
|
|
20
23
|
<input
|
|
21
24
|
id={htmlId}
|
|
22
25
|
className="fe-checkbox"
|
|
@@ -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
|
+
}
|