@imaginario27/air-ui-ds 1.16.0 → 1.17.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this package are documented in this file.
5
5
  Historical releases were reconstructed from git history (GitHub repository) and npm publish dates.
6
6
  Future releases will include detailed entries generated with Changesets.
7
7
 
8
+ ## 1.16.0 - 2026-07-07
9
+
10
+ Release type: minor.
11
+ Commits found in range: 1.
12
+
13
+ ### Added
14
+
15
+ 1. extract standalone Switch component and polish interaction transitions ([4c28c2d](https://github.com/imaginario27/air-ui/commit/4c28c2d34dc07f299f100e3badc491d67ebf8ca4))
16
+
17
+ - Package: @imaginario27/air-ui-ds.
18
+
8
19
  ## 1.15.3 - 2026-07-03
9
20
 
10
21
  Release type: patch.
@@ -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,474 @@
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
+
188
+ <!-- Trailing drop zone: lets an item be dragged into the last position -->
189
+ <div
190
+ v-if="sortingType === RepeatingFieldSortingType.DRAG && draggedIndex !== null && draggedIndex !== items.length - 1"
191
+ @dragover.prevent="handleDragOver(items.length, $event)"
192
+ @drop.prevent="handleDrop(items.length, $event)"
193
+ >
194
+ <DragPlaceholder
195
+ v-if="dragOverIndex === items.length"
196
+ aria-hidden="true"
197
+ :text="dragPlaceholderText"
198
+ :showText="showDragPlaceholderText"
199
+ :textClass="dragPlaceholderTextClass"
200
+ :class="['h-10', dragPlaceholderClass]"
201
+ />
202
+ <div v-else class="h-4" />
203
+ </div>
204
+ </div>
205
+
206
+ <!-- Help Text (bottom) -->
207
+ <HelpText
208
+ v-if="helpTextPosition === Position.BOTTOM"
209
+ :text="helpText"
210
+ :error="error"
211
+ />
212
+ </div>
213
+ </template>
214
+
215
+ <script setup lang="ts">
216
+ const props = defineProps({
217
+ id: {
218
+ type: String as PropType<string>,
219
+ required: true,
220
+ },
221
+ label: String as PropType<string>,
222
+ modelValue: {
223
+ type: Array as PropType<unknown[]>,
224
+ default: () => [],
225
+ },
226
+ defaultValue: {
227
+ type: [Object, Array, String, Number, Boolean] as PropType<unknown>,
228
+ default: () => ({}),
229
+ },
230
+ addItemAriaLabel: {
231
+ type: String as PropType<string>,
232
+ default: 'Add item',
233
+ },
234
+ removeItemAriaLabel: {
235
+ type: String as PropType<string>,
236
+ default: 'Remove item',
237
+ },
238
+ addButtonText: {
239
+ type: String as PropType<string>,
240
+ default: 'Add item',
241
+ },
242
+ removeButtonText: {
243
+ type: String as PropType<string>,
244
+ default: 'Remove item',
245
+ },
246
+ actionsOrientation: {
247
+ type: String as PropType<Orientation>,
248
+ default: Orientation.VERTICAL,
249
+ validator: (value: Orientation) => Object.values(Orientation).includes(value),
250
+ },
251
+ sortingType: {
252
+ type: String as PropType<RepeatingFieldSortingType>,
253
+ default: RepeatingFieldSortingType.NONE,
254
+ validator: (value: RepeatingFieldSortingType) => Object.values(RepeatingFieldSortingType).includes(value),
255
+ },
256
+ moveUpAriaLabel: {
257
+ type: String as PropType<string>,
258
+ default: 'Move item up',
259
+ },
260
+ moveDownAriaLabel: {
261
+ type: String as PropType<string>,
262
+ default: 'Move item down',
263
+ },
264
+ moveUpButtonText: {
265
+ type: String as PropType<string>,
266
+ default: 'Move up',
267
+ },
268
+ moveDownButtonText: {
269
+ type: String as PropType<string>,
270
+ default: 'Move down',
271
+ },
272
+ dragHandleAriaLabel: {
273
+ type: String as PropType<string>,
274
+ default: 'Drag to reorder item',
275
+ },
276
+ moveUpIcon: {
277
+ type: String as PropType<string>,
278
+ default: 'mdi:arrow-up',
279
+ },
280
+ moveDownIcon: {
281
+ type: String as PropType<string>,
282
+ default: 'mdi:arrow-down',
283
+ },
284
+ dragHandleIcon: {
285
+ type: String as PropType<string>,
286
+ default: 'mdi:drag-vertical',
287
+ },
288
+ dragPlaceholderText: {
289
+ type: String as PropType<string>,
290
+ default: 'Drop here',
291
+ },
292
+ showDragPlaceholderText: {
293
+ type: Boolean as PropType<boolean>,
294
+ default: true,
295
+ },
296
+ dragPlaceholderClass: String as PropType<string>,
297
+ dragPlaceholderTextClass: String as PropType<string>,
298
+ helpText: String as PropType<string>,
299
+ helpTextPosition: {
300
+ type: String as PropType<Position>,
301
+ default: Position.BOTTOM,
302
+ validator: (value: Position) => Object.values(Position).includes(value),
303
+ },
304
+ error: {
305
+ type: String as PropType<string>,
306
+ default: '',
307
+ },
308
+ required: {
309
+ type: Boolean as PropType<boolean>,
310
+ default: false,
311
+ },
312
+ validator: {
313
+ type: Function as PropType<(value: unknown[]) => string | null>,
314
+ default: () => null,
315
+ },
316
+ disabled: {
317
+ type: Boolean as PropType<boolean>,
318
+ default: false,
319
+ },
320
+ })
321
+
322
+ const emit = defineEmits(['update:modelValue', 'update:error'])
323
+
324
+ // Composables
325
+ const validationMode = useInjectedValidationMode()
326
+
327
+ // Computed
328
+ const hasError = computed(() => props.error !== '')
329
+
330
+ const cloneValue = <T,>(value: T): T => structuredClone(toRaw(value))
331
+
332
+ const createDefaultItem = () => cloneValue(props.defaultValue)
333
+
334
+ const normalizeItems = (list: unknown[]): unknown[] => (list.length ? [...list] : [createDefaultItem()])
335
+
336
+ // Local draft state: structural edits (add, remove, move) sync to the
337
+ // parent via emit; item content is owned by whatever the slot renders.
338
+ const localItems = ref<unknown[]>(normalizeItems(props.modelValue))
339
+
340
+ watch(
341
+ () => props.modelValue,
342
+ value => {
343
+ localItems.value = normalizeItems(value)
344
+ },
345
+ { deep: true }
346
+ )
347
+
348
+ const items = computed(() => localItems.value)
349
+
350
+ // Drag state
351
+ const draggedIndex = ref<number | null>(null)
352
+ const dragOverIndex = ref<number | null>(null)
353
+
354
+ const addItem = () => {
355
+ const nextItems = [...localItems.value, createDefaultItem()]
356
+ localItems.value = nextItems
357
+ emit('update:modelValue', nextItems)
358
+ }
359
+
360
+ const removeItem = (index: number) => {
361
+ if (localItems.value.length <= 1) {
362
+ return
363
+ }
364
+
365
+ const nextItems = localItems.value.filter((_, currentIndex) => currentIndex !== index)
366
+ localItems.value = nextItems
367
+ emit('update:modelValue', nextItems)
368
+ }
369
+
370
+ const isMoveUpDisabled = (index: number): boolean => props.disabled || index <= 0
371
+
372
+ const isMoveDownDisabled = (index: number): boolean => props.disabled || index >= items.value.length - 1
373
+
374
+ const moveItem = (fromIndex: number, toIndex: number) => {
375
+ if (props.disabled) {
376
+ return
377
+ }
378
+
379
+ if (toIndex < 0 || toIndex > items.value.length - 1 || toIndex === fromIndex) {
380
+ return
381
+ }
382
+
383
+ const nextItems = [...localItems.value]
384
+ const [movedItem] = nextItems.splice(fromIndex, 1)
385
+ nextItems.splice(toIndex, 0, movedItem)
386
+
387
+ localItems.value = nextItems
388
+ emit('update:modelValue', nextItems)
389
+ }
390
+
391
+ const moveItemUp = (index: number) => moveItem(index, index - 1)
392
+ const moveItemDown = (index: number) => moveItem(index, index + 1)
393
+
394
+ const handleDragStart = (index: number, event: DragEvent) => {
395
+ if (props.disabled) {
396
+ event.preventDefault()
397
+ return
398
+ }
399
+
400
+ draggedIndex.value = index
401
+ dragOverIndex.value = index
402
+ event.dataTransfer?.setData('text/plain', String(index))
403
+ if (event.dataTransfer) {
404
+ event.dataTransfer.effectAllowed = 'move'
405
+ }
406
+ }
407
+
408
+ const handleDragOver = (index: number, event: DragEvent) => {
409
+ if (props.sortingType !== RepeatingFieldSortingType.DRAG || draggedIndex.value === null) {
410
+ return
411
+ }
412
+
413
+ event.preventDefault()
414
+ dragOverIndex.value = index
415
+ }
416
+
417
+ const handleDrop = (index: number, event: DragEvent) => {
418
+ if (props.sortingType !== RepeatingFieldSortingType.DRAG || draggedIndex.value === null) {
419
+ return
420
+ }
421
+
422
+ event.preventDefault()
423
+
424
+ const fromIndex = draggedIndex.value
425
+ // The placeholder renders directly above the hovered row, so the dragged
426
+ // row must land immediately before it. Removing fromIndex first shifts
427
+ // every index after it back by one, so a downward move needs -1 to still
428
+ // land before the hovered row instead of after it.
429
+ const toIndex = index > fromIndex ? index - 1 : index
430
+ moveItem(fromIndex, toIndex)
431
+
432
+ draggedIndex.value = null
433
+ dragOverIndex.value = null
434
+ }
435
+
436
+ const handleDragEnd = () => {
437
+ draggedIndex.value = null
438
+ dragOverIndex.value = null
439
+ }
440
+
441
+ const handleDragHandleKeydown = (index: number, event: KeyboardEvent) => {
442
+ if (props.disabled) {
443
+ return
444
+ }
445
+
446
+ if (event.key === 'ArrowUp') {
447
+ event.preventDefault()
448
+ moveItemUp(index)
449
+ return
450
+ }
451
+
452
+ if (event.key === 'ArrowDown') {
453
+ event.preventDefault()
454
+ moveItemDown(index)
455
+ }
456
+ }
457
+
458
+ const runValidation = (value: unknown[]) => {
459
+ if (!props.required || !props.validator) return
460
+
461
+ const result = props.validator(value)
462
+ emit('update:error', result ?? '')
463
+ }
464
+
465
+ watch(
466
+ () => props.modelValue,
467
+ value => {
468
+ if (validationMode.value === FormValidationMode.BLUR) {
469
+ runValidation(value)
470
+ }
471
+ },
472
+ { deep: true }
473
+ )
474
+ </script>