@imaginario27/air-ui-ds 1.16.0 → 1.17.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.
@@ -8,7 +8,7 @@
8
8
  'rounded-lg',
9
9
  hasShadow ? 'shadow-sm' : undefined,
10
10
  'w-full',
11
- hasBackgroundHover && 'hover:bg-background-neutral-subtlest/40',
11
+ hasBackgroundHover && 'transition-colors duration-200 hover:bg-background-neutral-subtlest/40',
12
12
  ]"
13
13
  >
14
14
  <slot />
@@ -1,119 +1,457 @@
1
- <template>
2
- <div class="w-full flex flex-col gap-4">
3
- <div
4
- v-for="(item, index) in items"
5
- :key="index"
6
- class="flex items-start gap-4"
7
- >
8
- <!-- Content slot -->
9
- <div class="flex-grow">
10
- <slot
11
- :item="item"
12
- :index="index"
13
- />
14
- </div>
15
-
16
- <!-- Action buttons -->
17
- <div class="flex flex-col items-center gap-2 mt-2">
18
- <!-- Add button (show only for last item) -->
19
- <ActionIconButton
20
- v-if="index === items.length - 1"
21
- :styleType="ButtonStyleType.NEUTRAL_OUTLINED"
22
- icon="mdi:plus-circle-outline"
23
- :size="ButtonSize.SM"
24
- :ariaLabel="addItemAriaLabel"
25
- @click="addItem"
26
- />
27
-
28
- <!-- Delete button (show for all except when there's only one item) -->
29
- <ActionIconButton
30
- v-if="items.length > 1"
31
- :styleType="ButtonStyleType.DELETE_SOFT"
32
- icon="mdi:minus-circle-outline"
33
- :size="ButtonSize.SM"
34
- :ariaLabel="removeItemAriaLabel"
35
- @click="removeItem(index)"
36
- />
37
- </div>
38
- </div>
39
- </div>
40
- </template>
41
-
42
- <script setup lang="ts">
43
- const props = defineProps({
44
- modelValue: {
45
- type: Array,
46
- default: () => [],
47
- },
48
- defaultValue: {
49
- type: Object,
50
- default: () => ({}),
51
- },
52
- addItemAriaLabel: {
53
- type: String as PropType<string>,
54
- default: 'Add item',
55
- },
56
- removeItemAriaLabel: {
57
- type: String as PropType<string>,
58
- default: 'Remove item',
59
- },
60
- })
61
-
62
- const emit = defineEmits(['update:modelValue'])
63
-
64
- // Local items state
65
- const items = ref([...props.modelValue])
66
-
67
- // CORREGIDO: Flag para evitar bucles infinitos
68
- const isUpdatingFromParent = ref(false)
69
-
70
- // CORREGIDO: Watch para cambios externos con protección
71
- watch(
72
- () => props.modelValue,
73
- (newValue) => {
74
- if (!isUpdatingFromParent.value) {
75
- items.value = [...newValue]
76
- }
77
- },
78
- { deep: true }
79
- )
80
-
81
- // CORREGIDO: Watch para cambios internos con protección
82
- watch(
83
- items,
84
- (newValue) => {
85
- isUpdatingFromParent.value = true
86
- emit('update:modelValue', newValue)
87
- nextTick(() => {
88
- isUpdatingFromParent.value = false
89
- })
90
- },
91
- { deep: true }
92
- )
93
-
94
- // Add new item
95
- const addItem = () => {
96
- // CORREGIDO: Crear una copia profunda del defaultValue
97
- const newItem = JSON.parse(JSON.stringify(props.defaultValue))
98
- items.value.push(newItem)
99
- }
100
-
101
- // Remove item at index
102
- const removeItem = (index: number) => {
103
- if (items.value.length > 1) {
104
- items.value.splice(index, 1)
105
- }
106
- }
107
-
108
- // CORREGIDO: Inicialización más segura
109
- if (props.modelValue.length === 0) {
110
- // Solo inicializar si no hay datos del padre
111
- nextTick(() => {
112
- if (items.value.length === 0) {
113
- addItem()
114
- }
115
- })
116
- } else {
117
- items.value = [...props.modelValue]
118
- }
119
- </script>
1
+ <template>
2
+ <div :class="['flex flex-col', 'w-full', 'gap-2']">
3
+ <!-- Label -->
4
+ <label
5
+ v-if="label"
6
+ :for="id"
7
+ :class="[
8
+ 'text-sm',
9
+ 'font-semibold',
10
+ 'text-left',
11
+ hasError && 'text-text-error',
12
+ ]"
13
+ >
14
+ {{ label }}
15
+ </label>
16
+
17
+ <!-- Help Text (top) -->
18
+ <HelpText
19
+ v-if="helpTextPosition === Position.TOP"
20
+ :text="helpText"
21
+ :error="error"
22
+ />
23
+
24
+ <div class="w-full flex flex-col gap-4">
25
+ <template
26
+ v-for="(item, index) in items"
27
+ :key="index"
28
+ >
29
+ <!-- Drop placeholder -->
30
+ <DragPlaceholder
31
+ v-if="sortingType === RepeatingFieldSortingType.DRAG && draggedIndex !== null && dragOverIndex === index && draggedIndex !== index"
32
+ aria-hidden="true"
33
+ :text="dragPlaceholderText"
34
+ :showText="showDragPlaceholderText"
35
+ :textClass="dragPlaceholderTextClass"
36
+ :class="['h-10', dragPlaceholderClass]"
37
+ @dragover.prevent="handleDragOver(index, $event)"
38
+ @drop.prevent="handleDrop(index, $event)"
39
+ />
40
+
41
+ <div class="flex flex-col gap-2">
42
+ <div
43
+ class="flex items-start gap-4"
44
+ @dragover.prevent="handleDragOver(index, $event)"
45
+ @drop.prevent="handleDrop(index, $event)"
46
+ >
47
+ <!-- Drag handle -->
48
+ <button
49
+ v-if="sortingType === RepeatingFieldSortingType.DRAG"
50
+ type="button"
51
+ :draggable="!disabled"
52
+ :aria-label="dragHandleAriaLabel"
53
+ :disabled="disabled"
54
+ :class="[
55
+ 'flex items-center justify-center',
56
+ 'bg-transparent border-0 p-0 mt-2',
57
+ 'cursor-move! active:cursor-grabbing!',
58
+ 'text-icon-neutral-default',
59
+ 'disabled:cursor-not-allowed',
60
+ ]"
61
+ @dragstart="handleDragStart(index, $event)"
62
+ @dragend="handleDragEnd"
63
+ @keydown="handleDragHandleKeydown(index, $event)"
64
+ >
65
+ <Icon :name="dragHandleIcon" iconClass="w-[16px] h-[16px]" />
66
+ </button>
67
+
68
+ <!-- Content slot -->
69
+ <div class="grow">
70
+ <slot
71
+ :id="id"
72
+ :item="item"
73
+ :index="index"
74
+ />
75
+ </div>
76
+
77
+ <!-- Vertical action buttons (desktop only; actionsOrientation is vertical) -->
78
+ <div
79
+ v-if="actionsOrientation === Orientation.VERTICAL"
80
+ class="hidden md:flex flex-col gap-2"
81
+ >
82
+ <ActionIconButton
83
+ v-if="sortingType === RepeatingFieldSortingType.BUTTONS"
84
+ :styleType="ButtonStyleType.NEUTRAL_TRANSPARENT"
85
+ :icon="moveUpIcon"
86
+ :size="ButtonSize.SM"
87
+ :ariaLabel="moveUpAriaLabel"
88
+ :disabled="isMoveUpDisabled(index)"
89
+ @click="moveItemUp(index)"
90
+ />
91
+
92
+ <ActionIconButton
93
+ v-if="sortingType === RepeatingFieldSortingType.BUTTONS"
94
+ :styleType="ButtonStyleType.NEUTRAL_TRANSPARENT"
95
+ :icon="moveDownIcon"
96
+ :size="ButtonSize.SM"
97
+ :ariaLabel="moveDownAriaLabel"
98
+ :disabled="isMoveDownDisabled(index)"
99
+ @click="moveItemDown(index)"
100
+ />
101
+
102
+ <!-- Add button (show only for last item) -->
103
+ <ActionIconButton
104
+ v-if="index === items.length - 1"
105
+ :styleType="ButtonStyleType.NEUTRAL_OUTLINED"
106
+ icon="mdi:plus-circle-outline"
107
+ :size="ButtonSize.SM"
108
+ :ariaLabel="addItemAriaLabel"
109
+ :disabled="disabled"
110
+ @click="addItem"
111
+ />
112
+
113
+ <!-- Delete button (show for all except when there's only one item) -->
114
+ <ActionIconButton
115
+ v-if="items.length > 1"
116
+ :styleType="ButtonStyleType.DELETE_SOFT"
117
+ icon="mdi:minus-circle-outline"
118
+ :size="ButtonSize.SM"
119
+ :ariaLabel="removeItemAriaLabel"
120
+ :disabled="disabled"
121
+ @click="removeItem(index)"
122
+ />
123
+ </div>
124
+ </div>
125
+
126
+ <!-- Horizontal action buttons: always shown when actionsOrientation is horizontal,
127
+ and as the mobile fallback for the vertical action column above. Buttons stack
128
+ in a column on mobile and use isMobileFullWidth to span the full row width. -->
129
+ <div
130
+ :class="[
131
+ actionsOrientation === Orientation.HORIZONTAL ? 'flex' : 'flex md:hidden',
132
+ 'flex-col md:flex-row flex-wrap items-stretch md:items-center gap-2',
133
+ ]"
134
+ >
135
+ <ActionButton
136
+ v-if="sortingType === RepeatingFieldSortingType.BUTTONS"
137
+ :styleType="ButtonStyleType.NEUTRAL_TRANSPARENT"
138
+ :text="moveUpButtonText"
139
+ :icon="moveUpIcon"
140
+ :iconPosition="IconPosition.LEFT"
141
+ :size="ButtonSize.MD"
142
+ :isMobileFullWidth="true"
143
+ :disabled="isMoveUpDisabled(index)"
144
+ @click="moveItemUp(index)"
145
+ />
146
+
147
+ <ActionButton
148
+ v-if="sortingType === RepeatingFieldSortingType.BUTTONS"
149
+ :styleType="ButtonStyleType.NEUTRAL_TRANSPARENT"
150
+ :text="moveDownButtonText"
151
+ :icon="moveDownIcon"
152
+ :iconPosition="IconPosition.LEFT"
153
+ :size="ButtonSize.MD"
154
+ :isMobileFullWidth="true"
155
+ :disabled="isMoveDownDisabled(index)"
156
+ @click="moveItemDown(index)"
157
+ />
158
+
159
+ <!-- Add button (show only for last item) -->
160
+ <ActionButton
161
+ v-if="index === items.length - 1"
162
+ :styleType="ButtonStyleType.NEUTRAL_OUTLINED"
163
+ :text="addButtonText"
164
+ icon="mdi:plus-circle-outline"
165
+ :iconPosition="IconPosition.LEFT"
166
+ :size="ButtonSize.MD"
167
+ :isMobileFullWidth="true"
168
+ :disabled="disabled"
169
+ @click="addItem"
170
+ />
171
+
172
+ <!-- Delete button (show for all except when there's only one item) -->
173
+ <ActionButton
174
+ v-if="items.length > 1"
175
+ :styleType="ButtonStyleType.DELETE_SOFT"
176
+ :text="removeButtonText"
177
+ icon="mdi:minus-circle-outline"
178
+ :iconPosition="IconPosition.LEFT"
179
+ :size="ButtonSize.MD"
180
+ :isMobileFullWidth="true"
181
+ :disabled="disabled"
182
+ @click="removeItem(index)"
183
+ />
184
+ </div>
185
+ </div>
186
+ </template>
187
+ </div>
188
+
189
+ <!-- Help Text (bottom) -->
190
+ <HelpText
191
+ v-if="helpTextPosition === Position.BOTTOM"
192
+ :text="helpText"
193
+ :error="error"
194
+ />
195
+ </div>
196
+ </template>
197
+
198
+ <script setup lang="ts">
199
+ const props = defineProps({
200
+ id: {
201
+ type: String as PropType<string>,
202
+ required: true,
203
+ },
204
+ label: String as PropType<string>,
205
+ modelValue: {
206
+ type: Array as PropType<unknown[]>,
207
+ default: () => [],
208
+ },
209
+ defaultValue: {
210
+ type: [Object, Array, String, Number, Boolean] as PropType<unknown>,
211
+ default: () => ({}),
212
+ },
213
+ addItemAriaLabel: {
214
+ type: String as PropType<string>,
215
+ default: 'Add item',
216
+ },
217
+ removeItemAriaLabel: {
218
+ type: String as PropType<string>,
219
+ default: 'Remove item',
220
+ },
221
+ addButtonText: {
222
+ type: String as PropType<string>,
223
+ default: 'Add item',
224
+ },
225
+ removeButtonText: {
226
+ type: String as PropType<string>,
227
+ default: 'Remove item',
228
+ },
229
+ actionsOrientation: {
230
+ type: String as PropType<Orientation>,
231
+ default: Orientation.VERTICAL,
232
+ validator: (value: Orientation) => Object.values(Orientation).includes(value),
233
+ },
234
+ sortingType: {
235
+ type: String as PropType<RepeatingFieldSortingType>,
236
+ default: RepeatingFieldSortingType.NONE,
237
+ validator: (value: RepeatingFieldSortingType) => Object.values(RepeatingFieldSortingType).includes(value),
238
+ },
239
+ moveUpAriaLabel: {
240
+ type: String as PropType<string>,
241
+ default: 'Move item up',
242
+ },
243
+ moveDownAriaLabel: {
244
+ type: String as PropType<string>,
245
+ default: 'Move item down',
246
+ },
247
+ moveUpButtonText: {
248
+ type: String as PropType<string>,
249
+ default: 'Move up',
250
+ },
251
+ moveDownButtonText: {
252
+ type: String as PropType<string>,
253
+ default: 'Move down',
254
+ },
255
+ dragHandleAriaLabel: {
256
+ type: String as PropType<string>,
257
+ default: 'Drag to reorder item',
258
+ },
259
+ moveUpIcon: {
260
+ type: String as PropType<string>,
261
+ default: 'mdi:arrow-up',
262
+ },
263
+ moveDownIcon: {
264
+ type: String as PropType<string>,
265
+ default: 'mdi:arrow-down',
266
+ },
267
+ dragHandleIcon: {
268
+ type: String as PropType<string>,
269
+ default: 'mdi:drag-vertical',
270
+ },
271
+ dragPlaceholderText: {
272
+ type: String as PropType<string>,
273
+ default: 'Drop here',
274
+ },
275
+ showDragPlaceholderText: {
276
+ type: Boolean as PropType<boolean>,
277
+ default: true,
278
+ },
279
+ dragPlaceholderClass: String as PropType<string>,
280
+ dragPlaceholderTextClass: String as PropType<string>,
281
+ helpText: String as PropType<string>,
282
+ helpTextPosition: {
283
+ type: String as PropType<Position>,
284
+ default: Position.BOTTOM,
285
+ validator: (value: Position) => Object.values(Position).includes(value),
286
+ },
287
+ error: {
288
+ type: String as PropType<string>,
289
+ default: '',
290
+ },
291
+ required: {
292
+ type: Boolean as PropType<boolean>,
293
+ default: false,
294
+ },
295
+ validator: {
296
+ type: Function as PropType<(value: unknown[]) => string | null>,
297
+ default: () => null,
298
+ },
299
+ disabled: {
300
+ type: Boolean as PropType<boolean>,
301
+ default: false,
302
+ },
303
+ })
304
+
305
+ const emit = defineEmits(['update:modelValue', 'update:error'])
306
+
307
+ // Composables
308
+ const validationMode = useInjectedValidationMode()
309
+
310
+ // Computed
311
+ const hasError = computed(() => props.error !== '')
312
+
313
+ const cloneValue = <T,>(value: T): T => structuredClone(toRaw(value))
314
+
315
+ const createDefaultItem = () => cloneValue(props.defaultValue)
316
+
317
+ const normalizeItems = (list: unknown[]): unknown[] => (list.length ? [...list] : [createDefaultItem()])
318
+
319
+ // Local draft state: structural edits (add, remove, move) sync to the
320
+ // parent via emit; item content is owned by whatever the slot renders.
321
+ const localItems = ref<unknown[]>(normalizeItems(props.modelValue))
322
+
323
+ watch(
324
+ () => props.modelValue,
325
+ value => {
326
+ localItems.value = normalizeItems(value)
327
+ },
328
+ { deep: true }
329
+ )
330
+
331
+ const items = computed(() => localItems.value)
332
+
333
+ // Drag state
334
+ const draggedIndex = ref<number | null>(null)
335
+ const dragOverIndex = ref<number | null>(null)
336
+
337
+ const addItem = () => {
338
+ const nextItems = [...localItems.value, createDefaultItem()]
339
+ localItems.value = nextItems
340
+ emit('update:modelValue', nextItems)
341
+ }
342
+
343
+ const removeItem = (index: number) => {
344
+ if (localItems.value.length <= 1) {
345
+ return
346
+ }
347
+
348
+ const nextItems = localItems.value.filter((_, currentIndex) => currentIndex !== index)
349
+ localItems.value = nextItems
350
+ emit('update:modelValue', nextItems)
351
+ }
352
+
353
+ const isMoveUpDisabled = (index: number): boolean => props.disabled || index <= 0
354
+
355
+ const isMoveDownDisabled = (index: number): boolean => props.disabled || index >= items.value.length - 1
356
+
357
+ const moveItem = (fromIndex: number, toIndex: number) => {
358
+ if (props.disabled) {
359
+ return
360
+ }
361
+
362
+ if (toIndex < 0 || toIndex > items.value.length - 1 || toIndex === fromIndex) {
363
+ return
364
+ }
365
+
366
+ const nextItems = [...localItems.value]
367
+ const [movedItem] = nextItems.splice(fromIndex, 1)
368
+ nextItems.splice(toIndex, 0, movedItem)
369
+
370
+ localItems.value = nextItems
371
+ emit('update:modelValue', nextItems)
372
+ }
373
+
374
+ const moveItemUp = (index: number) => moveItem(index, index - 1)
375
+ const moveItemDown = (index: number) => moveItem(index, index + 1)
376
+
377
+ const handleDragStart = (index: number, event: DragEvent) => {
378
+ if (props.disabled) {
379
+ event.preventDefault()
380
+ return
381
+ }
382
+
383
+ draggedIndex.value = index
384
+ dragOverIndex.value = index
385
+ event.dataTransfer?.setData('text/plain', String(index))
386
+ if (event.dataTransfer) {
387
+ event.dataTransfer.effectAllowed = 'move'
388
+ }
389
+ }
390
+
391
+ const handleDragOver = (index: number, event: DragEvent) => {
392
+ if (props.sortingType !== RepeatingFieldSortingType.DRAG || draggedIndex.value === null) {
393
+ return
394
+ }
395
+
396
+ event.preventDefault()
397
+ dragOverIndex.value = index
398
+ }
399
+
400
+ const handleDrop = (index: number, event: DragEvent) => {
401
+ if (props.sortingType !== RepeatingFieldSortingType.DRAG || draggedIndex.value === null) {
402
+ return
403
+ }
404
+
405
+ event.preventDefault()
406
+
407
+ const fromIndex = draggedIndex.value
408
+ // The placeholder renders directly above the hovered row, so the dragged
409
+ // row must land immediately before it. Removing fromIndex first shifts
410
+ // every index after it back by one, so a downward move needs -1 to still
411
+ // land before the hovered row instead of after it.
412
+ const toIndex = index > fromIndex ? index - 1 : index
413
+ moveItem(fromIndex, toIndex)
414
+
415
+ draggedIndex.value = null
416
+ dragOverIndex.value = null
417
+ }
418
+
419
+ const handleDragEnd = () => {
420
+ draggedIndex.value = null
421
+ dragOverIndex.value = null
422
+ }
423
+
424
+ const handleDragHandleKeydown = (index: number, event: KeyboardEvent) => {
425
+ if (props.disabled) {
426
+ return
427
+ }
428
+
429
+ if (event.key === 'ArrowUp') {
430
+ event.preventDefault()
431
+ moveItemUp(index)
432
+ return
433
+ }
434
+
435
+ if (event.key === 'ArrowDown') {
436
+ event.preventDefault()
437
+ moveItemDown(index)
438
+ }
439
+ }
440
+
441
+ const runValidation = (value: unknown[]) => {
442
+ if (!props.required || !props.validator) return
443
+
444
+ const result = props.validator(value)
445
+ emit('update:error', result ?? '')
446
+ }
447
+
448
+ watch(
449
+ () => props.modelValue,
450
+ value => {
451
+ if (validationMode.value === FormValidationMode.BLUR) {
452
+ runValidation(value)
453
+ }
454
+ },
455
+ { deep: true }
456
+ )
457
+ </script>