@innertia-solutions/innertia-nuxt 0.1.38 → 0.1.40

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
@@ -4,8 +4,13 @@ All notable changes to `@innertia-solutions/innertia-nuxt` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Added
8
+
9
+ - `Kanban.Standard`: reordenamiento intra-columna con drag&drop (prop `reorderEndpoint`); orden por `position`, placeholder de inserción, y persistencia vía POST. Retrocompatible: sin la prop se comporta igual que antes (solo cross-column).
10
+
7
11
  ### Fixed
8
12
 
13
+ - `Forms.Select`: el dropdown ahora usa `dropdownScope:'window'` (Preline lo teletransporta al `<body>` y lo posiciona con floating-ui). Deja de recortarse dentro de contenedores con scroll —modales, paneles— y flota por encima (`z-[10000]`). Se expone `window.FloatingUIDOM` en el plugin de Preline y se agrega `@floating-ui/dom` como dependencia.
9
14
  - `Kanban.Standard`: en modo `bordered=false`, el `ring` de highlight al arrastrar sobre una columna se recortaba por arriba (el contenedor `overflow-x-auto` sin padding clippeaba los 2px del ring). Se agrega `p-1` al scroll container en ese modo.
10
15
 
11
16
  ### BREAKING CHANGES
@@ -111,9 +111,10 @@ const handleChange = (e: Event) => {
111
111
  @change="handleChange"
112
112
  data-hs-select='{
113
113
  "placeholder": "Seleccionar...",
114
+ "dropdownScope": "window",
114
115
  "toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
115
- "toggleClasses": "innertia-field hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 aria-expanded:!rounded-b-none aria-expanded:!border-b-transparent relative pe-9 flex gap-x-2 items-center text-nowrap cursor-pointer text-start",
116
- "dropdownClasses": "-mt-px z-50 w-full max-h-72 py-1 bg-[color:var(--field-dropdown-bg)] border border-[color:var(--field-border)] rounded-b-control overflow-hidden overflow-y-auto shadow-lg",
116
+ "toggleClasses": "innertia-field hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative pe-9 flex gap-x-2 items-center text-nowrap cursor-pointer text-start",
117
+ "dropdownClasses": "z-[10000] w-full max-h-72 py-1 bg-[color:var(--field-dropdown-bg)] border border-[color:var(--field-border)] rounded-control overflow-hidden overflow-y-auto shadow-lg",
117
118
  "optionClasses": "py-2 px-3 w-full text-sm text-foreground cursor-pointer bg-transparent hover:bg-muted-hover focus:outline-hidden focus:bg-muted-hover",
118
119
  "optionTemplate": "<div class=\"flex justify-between items-center w-full\"><span data-title></span><span class=\"hidden hs-selected:block\"><svg class=\"shrink-0 size-3.5 text-blue-600 dark:text-blue-500\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg></span></div>"
119
120
  }'
@@ -141,24 +142,10 @@ const handleChange = (e: Event) => {
141
142
 
142
143
  <style>
143
144
  /*
144
- * Preline HSSelect: cuando el dropdown está abierto, el trigger pierde
145
- * sus esquinas inferiores y el borde inferior se vuelve invisible para
146
- * fusionarse visualmente con el dropdown panel.
147
- *
148
- * Estilos globales (sin scoped) porque el HSSelect inyecta DOM fuera
149
- * del componente Vue (button generado, no del template).
145
+ * Con `dropdownScope:'window'` Preline teletransporta el panel del dropdown al
146
+ * <body> y lo posiciona con floating-ui (fixed + offset). Así escapa el overflow
147
+ * de cualquier contenedor con scroll (modales, paneles, etc.) y flota por encima.
148
+ * El trigger conserva sus bordes/esquinas completos porque el panel ya no se
149
+ * "fusiona" con él — flota separado.
150
150
  */
151
- .hs-select [aria-expanded="true"].innertia-field {
152
- border-bottom-left-radius: 0 !important;
153
- border-bottom-right-radius: 0 !important;
154
- border-bottom-color: transparent !important;
155
- }
156
-
157
- /* Pegar el dropdown al trigger eliminando cualquier offset/margen que
158
- * Preline pueda inyectar. Target amplio: cualquier div con la clase
159
- * `rounded-b-control` dentro del wrapper `.hs-select`. */
160
- .hs-select div[class*="rounded-b-control"] {
161
- top: 100% !important;
162
- margin-top: -1px !important;
163
- }
164
151
  </style>
@@ -56,6 +56,9 @@ const props = defineProps({
56
56
  states: { type: Array, required: true },
57
57
  moveMutation: { type: Function, default: null },
58
58
 
59
+ // Endpoint para persistir reorden (POST). Si null → solo cross-column, sin placeholder.
60
+ reorderEndpoint: { type: String, default: null },
61
+
59
62
  // Toolbar visibility
60
63
  showSearch: { type: Boolean, default: true },
61
64
  searchPlaceholder:{ type: String, default: 'Buscar...' },
@@ -355,6 +358,7 @@ const buildBody = () => ({
355
358
  per_page: props.perPage,
356
359
  search: search.value.trim(),
357
360
  ...(enrichedFilters.value.length ? { filters: enrichedFilters.value } : {}),
361
+ ...(props.reorderEndpoint ? { board: true } : {}),
358
362
  })
359
363
 
360
364
  const fetchAll = async () => {
@@ -433,6 +437,11 @@ const columnRows = computed(() => {
433
437
  const state = row[props.stateKey]
434
438
  if (map[state]) map[state].push(row)
435
439
  }
440
+ if (props.reorderEndpoint) {
441
+ for (const k of Object.keys(map)) {
442
+ map[k].sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
443
+ }
444
+ }
436
445
  return map
437
446
  })
438
447
 
@@ -442,21 +451,73 @@ const totalRendered = computed(() => rows.value.length)
442
451
  const draggedId = ref(null)
443
452
  const draggedFrom = ref(null)
444
453
  const dragOverState = ref(null)
454
+ const dragOverIndex = ref(null) // índice de inserción (placeholder) en la columna dragOverState
445
455
 
446
456
  const onDragStart = (row, state) => {
447
457
  draggedId.value = row.id
448
458
  draggedFrom.value = state
449
459
  }
450
- const onDragOver = (e, state) => { e.preventDefault(); dragOverState.value = state }
451
- const onDragLeave = () => { dragOverState.value = null }
460
+ const onDragOver = (e, state) => {
461
+ e.preventDefault()
462
+ dragOverState.value = state
463
+ if (!props.reorderEndpoint) return
464
+ if (dragOverIndex.value === null) dragOverIndex.value = columnRows.value[state]?.length ?? 0
465
+ }
466
+ const onDragLeave = () => { dragOverState.value = null; dragOverIndex.value = null }
467
+
468
+ // Llamado desde cada tarjeta: calcula si el cursor está en la mitad superior o inferior.
469
+ const onCardDragOver = (e, state, index) => {
470
+ if (!props.reorderEndpoint) return
471
+ e.preventDefault()
472
+ e.stopPropagation()
473
+ dragOverState.value = state
474
+ const rect = e.currentTarget.getBoundingClientRect()
475
+ const after = (e.clientY - rect.top) > rect.height / 2
476
+ dragOverIndex.value = after ? index + 1 : index
477
+ }
452
478
 
453
479
  const onDrop = async (targetState) => {
454
- dragOverState.value = null
455
480
  const id = draggedId.value
456
481
  const fromState = draggedFrom.value
457
- draggedId.value = null
482
+ const dropIndex = dragOverIndex.value
483
+ dragOverState.value = null
484
+ dragOverIndex.value = null
485
+ draggedId.value = null
458
486
  draggedFrom.value = null
459
- if (!id || fromState === targetState) return
487
+ if (!id) return
488
+
489
+ // Modo reorden (prop seteada): calcula vecinos y persiste posición.
490
+ if (props.reorderEndpoint) {
491
+ const colList = (columnRows.value[targetState] ?? []).filter(r => r.id !== id)
492
+ const idx = dropIndex == null ? colList.length : Math.min(dropIndex, colList.length)
493
+ const beforeId = idx > 0 ? colList[idx - 1].id : null // tarjeta arriba
494
+ const afterId = idx < colList.length ? colList[idx].id : null // tarjeta abajo
495
+
496
+ if (fromState === targetState && beforeId == null && afterId == null) return
497
+
498
+ const snapshot = rows.value.map(r => ({ ...r }))
499
+ const ridx = rows.value.findIndex(r => r.id === id)
500
+ if (ridx >= 0) {
501
+ const before = beforeId ? rows.value.find(r => r.id === beforeId) : null
502
+ const after = afterId ? rows.value.find(r => r.id === afterId) : null
503
+ const newPos = before && after ? (before.position + after.position) / 2
504
+ : before ? before.position + 1
505
+ : after ? after.position - 1
506
+ : 1
507
+ rows.value[ridx] = { ...rows.value[ridx], [props.stateKey]: targetState, position: newPos }
508
+ }
509
+ emit('move', { id, from: fromState, to: targetState })
510
+
511
+ try {
512
+ await api.post(props.reorderEndpoint, { id, column: targetState, before_id: beforeId, after_id: afterId })
513
+ } catch (e) {
514
+ rows.value = snapshot
515
+ }
516
+ return
517
+ }
518
+
519
+ // Modo legacy (sin reorderEndpoint): solo cross-column, comportamiento actual.
520
+ if (fromState === targetState) return
460
521
 
461
522
  // Optimistic update local (solo en endpoint mode).
462
523
  // En items mode el optimistic lo gestiona el moveMutation externo
@@ -747,25 +808,34 @@ defineExpose({ reload: fetchAll, rows })
747
808
  bordered ? 'bg-muted/40' : '',
748
809
  ]"
749
810
  >
811
+ <template v-for="(row, ci) in columnRows[state.key]" :key="row.id">
812
+ <div
813
+ v-if="reorderEndpoint && dragOverState === state.key && dragOverIndex === ci"
814
+ class="rounded-card border-2 border-dashed border-primary/50 bg-primary/5 h-10"
815
+ />
816
+ <div
817
+ draggable="true"
818
+ @dragstart="onDragStart(row, state.key)"
819
+ @dragover="onCardDragOver($event, state.key, ci)"
820
+ @click="handleCardClick(row)"
821
+ class="bg-card border border-card-line rounded-card p-3 cursor-grab active:cursor-grabbing hover:border-primary/40 hover:shadow-sm transition-all select-none"
822
+ :class="[
823
+ draggedId === row.id ? 'opacity-40' : '',
824
+ previewRow?.id === row.id ? 'ring-2 ring-primary/30' : '',
825
+ ]"
826
+ >
827
+ <slot name="card" :row="row" :state="state">
828
+ <div class="space-y-1">
829
+ <p class="text-sm font-medium text-foreground truncate">{{ row.name ?? row.title ?? row.id }}</p>
830
+ <p v-if="row.description" class="text-xs text-muted-foreground line-clamp-2">{{ row.description }}</p>
831
+ </div>
832
+ </slot>
833
+ </div>
834
+ </template>
750
835
  <div
751
- v-for="row in columnRows[state.key]"
752
- :key="row.id"
753
- draggable="true"
754
- @dragstart="onDragStart(row, state.key)"
755
- @click="handleCardClick(row)"
756
- class="bg-card border border-card-line rounded-card p-3 cursor-grab active:cursor-grabbing hover:border-primary/40 hover:shadow-sm transition-all select-none"
757
- :class="[
758
- draggedId === row.id ? 'opacity-40' : '',
759
- previewRow?.id === row.id ? 'ring-2 ring-primary/30' : '',
760
- ]"
761
- >
762
- <slot name="card" :row="row" :state="state">
763
- <div class="space-y-1">
764
- <p class="text-sm font-medium text-foreground truncate">{{ row.name ?? row.title ?? row.id }}</p>
765
- <p v-if="row.description" class="text-xs text-muted-foreground line-clamp-2">{{ row.description }}</p>
766
- </div>
767
- </slot>
768
- </div>
836
+ v-if="reorderEndpoint && dragOverState === state.key && dragOverIndex === (columnRows[state.key]?.length ?? 0) && columnRows[state.key]?.length"
837
+ class="rounded-card border-2 border-dashed border-primary/50 bg-primary/5 h-10"
838
+ />
769
839
 
770
840
  <div
771
841
  v-if="!columnRows[state.key]?.length"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -31,6 +31,7 @@
31
31
  "vue": ">=3.5.0"
32
32
  },
33
33
  "dependencies": {
34
+ "@floating-ui/dom": "^1.7.6",
34
35
  "@nuxtjs/seo": "^3.4.0",
35
36
  "@pinia/nuxt": "^0.11.3",
36
37
  "@tabler/icons-vue": "^3.44.0",
@@ -3,6 +3,7 @@ declare global {
3
3
  HSStaticMethods?: { autoInit?: () => void }
4
4
  HSSelect?: new (el: HTMLElement) => { destroy?: () => void }
5
5
  HSThemeAppearance?: { init?: () => void }
6
+ FloatingUIDOM?: unknown
6
7
  }
7
8
  }
8
9
 
@@ -10,6 +11,13 @@ export default defineNuxtPlugin(async () => {
10
11
  if (!process.client) return
11
12
 
12
13
  try {
14
+ // Preline 3 lee `window.FloatingUIDOM` (global, no import) para posicionar
15
+ // dropdowns con `dropdownScope:'window'` (los teletransporta al body y flota
16
+ // con floating-ui, escapando cualquier overflow — ej. selects dentro de modales).
17
+ if (!window.FloatingUIDOM) {
18
+ window.FloatingUIDOM = await import('@floating-ui/dom')
19
+ }
20
+
13
21
  await import('preline')
14
22
 
15
23
  const initPreline = () => {