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.
@@ -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="border rounded-lg bg-white dark:bg-slate-800 shadow-sm relative dark:border-slate-600">
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="block mb-4 ml-2 text-sm font-medium text-slate-900 dark:text-slate-100">
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="space-y-2 rounded-lg">
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="flex justify-between items-center px-4 py-3 border-b bg-slate-50 dark:bg-slate-700 dark:border-slate-600 rounded-t-lg">
129
- <div className="flex items-center gap-2">
130
- <button
131
- type="button"
132
- aria-label={`Mover ${itemLabel} ${index + 1}`}
133
- className="cursor-move drag-handle text-slate-400"
134
- {...attributes}
135
- {...listeners}>
136
- <IconComponent name="drag" />
137
- </button>
138
- <h4 className="text-md font-semibold text-slate-800 dark:text-slate-100">
139
- {itemLabel} #{index + 1}
140
- </h4>
141
- </div>
142
-
143
- <div className="flex items-center space-x-4 text-slate-400">
144
- <button
145
- type="button"
146
- aria-label={`Duplicar ${itemLabel} ${index + 1}`}
147
- className="hover:text-blue-500 transition mr-2"
148
- onClick={() => set([
149
- ...keyed.slice(0, index + 1),
150
- { ...group, __key: `grupo-${nextKey.current++}` },
151
- ...keyed.slice(index + 1),
152
- ])}>
153
- <IconComponent name="copy" />
154
- </button>
155
-
156
- <button
157
- type="button"
158
- aria-label={`${removeButtonLabel} ${index + 1}`}
159
- className="text-red-800 dark:text-red-400 text-sm"
160
- onClick={() => set(keyed.filter((_, position) => position !== index))}>
161
- <IconComponent name="delete" />
162
- </button>
163
-
164
- <button
165
- type="button"
166
- aria-label={`Expandir ${itemLabel} ${index + 1}`}
167
- aria-expanded={! group._collapsed}
168
- className="hover:text-slate-600 dark:hover:text-slate-300 transition"
169
- onClick={() => updateAt(index, '_collapsed', ! group._collapsed)}>
170
- <IconComponent name={group._collapsed ? 'up' : 'down'} />
171
- </button>
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="p-4">
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="fe-button fe-mt"
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="fe-file-drop"
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 text-red-600">
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="text-red-600">El tamaño total supera el máximo permitido.</p> : null}
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="ml-2 text-sm font-medium text-gray-900 dark:text-white">
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="ml-2 text-sm font-medium text-gray-900 dark:text-white">
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
+ }