@innertia-solutions/innertia-nuxt 0.1.11 → 0.1.13

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/app.config.ts +10 -10
  3. package/components/Attention.vue +269 -0
  4. package/components/Auth/LoginForm.vue +1 -1
  5. package/components/Chart/index.vue +193 -0
  6. package/components/Container.vue +116 -0
  7. package/components/CopyButton.vue +98 -0
  8. package/components/Files/Card.vue +215 -0
  9. package/components/Files/Preview.vue +675 -0
  10. package/components/Files/Upload.vue +408 -0
  11. package/components/Files/UploadModal.vue +83 -0
  12. package/components/Forms/InputPin.vue +162 -0
  13. package/components/InfoToolbar.vue +54 -0
  14. package/components/Kanban/Standard.vue +588 -0
  15. package/components/Loader.vue +358 -0
  16. package/components/MobileBlocker.vue +5 -5
  17. package/components/MobileLoginPicker.vue +13 -13
  18. package/components/Modal/Base.vue +6 -0
  19. package/components/Modal/Confirm.vue +89 -0
  20. package/components/Modal/DeleteConfirm.vue +14 -31
  21. package/components/Modal/Form.vue +76 -0
  22. package/components/Modal/Host.vue +58 -0
  23. package/components/Modal/Prompt.vue +74 -0
  24. package/components/Modal.vue +119 -34
  25. package/components/Organization/Picker.vue +3 -3
  26. package/components/Table/Standard.vue +198 -25
  27. package/components/Table/index.vue +169 -369
  28. package/components/Table/views/ViewCards.vue +74 -0
  29. package/components/Table/views/ViewDatabase.vue +236 -0
  30. package/components/Table/views/ViewTable.vue +299 -0
  31. package/components/Toast/Alert.vue +16 -111
  32. package/components/Toast/Container.vue +4 -29
  33. package/components/Toast/Notification.vue +8 -43
  34. package/components/Toast/Process.vue +8 -86
  35. package/components/Toast/Stack.vue +59 -0
  36. package/components/Toast/Standard.vue +115 -0
  37. package/components/Tree/Standard.vue +49 -4
  38. package/components/Workflow/Board.vue +356 -0
  39. package/components/Workflow/Builder.vue +454 -0
  40. package/components/Workflow/Form.vue +526 -0
  41. package/components/Workflow/Viewer.vue +225 -0
  42. package/components/Workflow/edges/TransitionEdge.vue +84 -0
  43. package/components/Workflow/nodes/StepNode.vue +88 -0
  44. package/composables/useAuth.js +1 -1
  45. package/composables/useClipboard.js +79 -0
  46. package/composables/useConfetti.js +194 -0
  47. package/composables/useContext.js +53 -5
  48. package/composables/useFile.js +189 -0
  49. package/composables/useMobileGuard.ts +21 -21
  50. package/composables/useModal.js +71 -0
  51. package/composables/useOrganization.ts +3 -3
  52. package/composables/useToast.js +38 -65
  53. package/composables/useWorkflow/form.js +162 -0
  54. package/composables/useWorkflow/index.js +18 -0
  55. package/composables/useWorkflow/view.js +220 -0
  56. package/composables/useWorkflowLayout.js +162 -0
  57. package/css/tokens.css +10 -10
  58. package/docs/superpowers/plans/2026-05-27-nuxt-apps-to-contexts-rename.md +630 -0
  59. package/docs/superpowers/specs/2026-05-27-nuxt-apps-to-contexts-rename-design.md +113 -0
  60. package/middleware/{03.apps.global.ts → 03.contexts.global.ts} +25 -25
  61. package/nuxt.config.ts +1 -1
  62. package/package.json +13 -8
  63. package/stores/modal.js +48 -0
  64. package/stores/toast.js +156 -117
  65. package/composables/useApp.ts +0 -46
@@ -0,0 +1,58 @@
1
+ <script setup>
2
+ /**
3
+ * <ModalHost /> — Mount-once en el layout root para habilitar useModal().
4
+ *
5
+ * Renderiza la cola de modales programáticos del store. Cada entry sabe
6
+ * dispatchar al componente adecuado según su `kind`.
7
+ */
8
+ import { useModalStore } from '../../stores/modal'
9
+
10
+ const store = useModalStore()
11
+
12
+ const closeWith = (id, value) => store.resolve(id, value)
13
+ </script>
14
+
15
+ <template>
16
+ <template v-for="entry in store.queue" :key="entry.id">
17
+ <!-- Confirm -->
18
+ <ModalConfirm
19
+ v-if="entry.kind === 'confirm'"
20
+ :model-value="true"
21
+ v-bind="entry.props"
22
+ @confirm="closeWith(entry.id, true)"
23
+ @cancel="closeWith(entry.id, false)"
24
+ @update:model-value="v => !v && closeWith(entry.id, false)"
25
+ />
26
+
27
+ <!-- Alert (confirm sin cancel) -->
28
+ <ModalConfirm
29
+ v-else-if="entry.kind === 'alert'"
30
+ :model-value="true"
31
+ hide-cancel
32
+ v-bind="entry.props"
33
+ @confirm="closeWith(entry.id, undefined)"
34
+ @cancel="closeWith(entry.id, undefined)"
35
+ @update:model-value="v => !v && closeWith(entry.id, undefined)"
36
+ />
37
+
38
+ <!-- Prompt -->
39
+ <ModalPrompt
40
+ v-else-if="entry.kind === 'prompt'"
41
+ :model-value="true"
42
+ v-bind="entry.props"
43
+ @submit="value => closeWith(entry.id, value)"
44
+ @cancel="closeWith(entry.id, null)"
45
+ @update:model-value="v => !v && closeWith(entry.id, null)"
46
+ />
47
+
48
+ <!-- Custom component — recibe modelValue + props + emite close(value) -->
49
+ <component
50
+ v-else-if="entry.kind === 'component' && entry.component"
51
+ :is="entry.component"
52
+ :model-value="true"
53
+ v-bind="entry.props"
54
+ @close="value => closeWith(entry.id, value)"
55
+ @update:model-value="v => !v && closeWith(entry.id, undefined)"
56
+ />
57
+ </template>
58
+ </template>
@@ -0,0 +1,74 @@
1
+ <script setup>
2
+ /**
3
+ * <ModalPrompt> — Diálogo para capturar un string del usuario.
4
+ *
5
+ * Usado principalmente por useModal().prompt(). Emite 'submit' con el valor
6
+ * y 'cancel' al cerrar.
7
+ */
8
+ const props = defineProps({
9
+ modelValue: { type: Boolean, default: false },
10
+ title: { type: String, default: 'Ingresar valor' },
11
+ message: { type: String, default: '' },
12
+ placeholder: { type: String, default: '' },
13
+ defaultValue: { type: String, default: '' },
14
+ confirmText: { type: String, default: 'Aceptar' },
15
+ cancelText: { type: String, default: 'Cancelar' },
16
+ required: { type: Boolean, default: false },
17
+ })
18
+
19
+ const emit = defineEmits(['update:modelValue', 'submit', 'cancel'])
20
+
21
+ const value = ref(props.defaultValue)
22
+ const inputRef = ref(null)
23
+
24
+ watch(() => props.modelValue, async (v) => {
25
+ if (v) {
26
+ value.value = props.defaultValue
27
+ await nextTick()
28
+ inputRef.value?.focus()
29
+ }
30
+ })
31
+
32
+ const cancel = () => {
33
+ emit('update:modelValue', false)
34
+ emit('cancel')
35
+ }
36
+
37
+ const submit = () => {
38
+ if (props.required && !value.value.trim()) return
39
+ emit('submit', value.value)
40
+ }
41
+ </script>
42
+
43
+ <template>
44
+ <Modal
45
+ :model-value="modelValue"
46
+ :title="title"
47
+ size="sm"
48
+ :show-footer="true"
49
+ padding="md"
50
+ @update:model-value="emit('update:modelValue', $event)"
51
+ @close="cancel"
52
+ >
53
+ <p v-if="message" class="text-sm text-muted-foreground mb-3">{{ message }}</p>
54
+ <input
55
+ ref="inputRef"
56
+ v-model="value"
57
+ type="text"
58
+ :placeholder="placeholder"
59
+ class="innertia-field w-full"
60
+ @keydown.enter="submit"
61
+ />
62
+
63
+ <template #footer>
64
+ <AppButton :text="cancelText" severity="secondary" size="sm" @click="cancel" />
65
+ <AppButton
66
+ :text="confirmText"
67
+ severity="primary"
68
+ size="sm"
69
+ :disabled="required && !value.trim()"
70
+ @click="submit"
71
+ />
72
+ </template>
73
+ </Modal>
74
+ </template>
@@ -1,23 +1,69 @@
1
1
  <script setup>
2
+ import { IconX } from '@tabler/icons-vue'
3
+
4
+ /**
5
+ * <Modal> — Core modal. Sigue tokens semánticos (rounded-modal, bg-card,
6
+ * border-card-line). Variantes: center (default), drawer-right, drawer-left,
7
+ * fullscreen. Tamaños xs..3xl.
8
+ *
9
+ * No tiene lógica de negocio. Para casos típicos preferir:
10
+ * - <ModalConfirm> → confirmaciones con severities
11
+ * - <ModalForm> → modal con form, scroll y footer sticky
12
+ * - useModal() → API promise-based (alert/confirm/prompt/open)
13
+ */
2
14
  const props = defineProps({
3
- modelValue: { type: Boolean, default: false },
4
- title: { type: String, default: '' },
5
- size: { type: String, default: 'md', validator: v => ['xs','sm','md','lg','xl','2xl','3xl','fullscreen'].includes(v) },
6
- closable: { type: Boolean, default: true },
15
+ modelValue: { type: Boolean, default: false },
16
+ title: { type: String, default: '' },
17
+ size: { type: String, default: 'md', validator: v => ['xs','sm','md','lg','xl','2xl','3xl','fullscreen'].includes(v) },
18
+ variant: { type: String, default: 'center', validator: v => ['center','drawer-right','drawer-left','fullscreen'].includes(v) },
19
+ closable: { type: Boolean, default: true },
7
20
  backdropDismiss: { type: Boolean, default: true },
8
- showHeader: { type: Boolean, default: true },
9
- showFooter: { type: Boolean, default: false },
21
+ showHeader: { type: Boolean, default: true },
22
+ showFooter: { type: Boolean, default: false },
23
+ /** Hace el body scrollable con el header/footer sticky. */
24
+ scrollBody: { type: Boolean, default: true },
25
+ /** Padding del body. 'none' para que el slot controle su propio padding. */
26
+ padding: { type: String, default: 'md', validator: v => ['none','sm','md','lg'].includes(v) },
10
27
  })
11
28
 
12
29
  const emit = defineEmits(['update:modelValue', 'close'])
13
30
 
14
31
  const modalId = `modal-${Math.random().toString(36).slice(2, 9)}`
15
32
 
16
- const sizeClass = computed(() => ({
17
- xs: 'max-w-xs', sm: 'max-w-sm', md: 'max-w-md',
18
- lg: 'max-w-lg', xl: 'max-w-xl', '2xl': 'max-w-2xl',
19
- '3xl': 'max-w-3xl', fullscreen: 'max-w-full',
20
- }[props.size] ?? 'max-w-md'))
33
+ const isDrawer = computed(() => props.variant.startsWith('drawer'))
34
+ const isFullscreen = computed(() => props.variant === 'fullscreen' || props.size === 'fullscreen')
35
+
36
+ const sizeClass = computed(() => {
37
+ if (isFullscreen.value) return 'max-w-full w-full h-full'
38
+ return ({
39
+ xs: 'max-w-xs', sm: 'max-w-sm', md: 'max-w-md',
40
+ lg: 'max-w-lg', xl: 'max-w-xl', '2xl': 'max-w-2xl', '3xl': 'max-w-3xl',
41
+ }[props.size] ?? 'max-w-md')
42
+ })
43
+
44
+ const paddingClass = computed(() => ({
45
+ none: 'p-0', sm: 'p-3', md: 'p-5', lg: 'p-6',
46
+ }[props.padding] ?? 'p-5'))
47
+
48
+ const containerClass = computed(() => {
49
+ if (isFullscreen.value) return 'inset-0'
50
+ if (props.variant === 'drawer-right') return 'inset-y-0 right-0'
51
+ if (props.variant === 'drawer-left') return 'inset-y-0 left-0'
52
+ return 'inset-0 items-center justify-center p-4'
53
+ })
54
+
55
+ const panelClass = computed(() => {
56
+ if (isFullscreen.value) return 'w-full h-full rounded-none'
57
+ if (isDrawer.value) return `h-full ${sizeClass.value} rounded-none`
58
+ return `w-full ${sizeClass.value} rounded-modal`
59
+ })
60
+
61
+ const transitionName = computed(() => {
62
+ if (isFullscreen.value) return 'modal-fade'
63
+ if (props.variant === 'drawer-right') return 'modal-slide-right'
64
+ if (props.variant === 'drawer-left') return 'modal-slide-left'
65
+ return 'modal-pop'
66
+ })
21
67
 
22
68
  const close = () => {
23
69
  emit('update:modelValue', false)
@@ -31,54 +77,67 @@ const onBackdrop = (e) => {
31
77
  const onEsc = (e) => { if (e.key === 'Escape' && props.modelValue && props.closable) close() }
32
78
 
33
79
  watch(() => props.modelValue, v => {
34
- document.body.style.overflow = v ? 'hidden' : ''
80
+ if (typeof document !== 'undefined') {
81
+ document.body.style.overflow = v ? 'hidden' : ''
82
+ }
35
83
  })
36
84
 
37
85
  onMounted(() => document.addEventListener('keydown', onEsc))
38
86
  onUnmounted(() => {
39
87
  document.removeEventListener('keydown', onEsc)
40
- document.body.style.overflow = ''
88
+ if (typeof document !== 'undefined') document.body.style.overflow = ''
41
89
  })
42
90
  </script>
43
91
 
44
92
  <template>
45
93
  <Teleport v-if="modelValue" to="body">
46
- <Transition name="modal" appear>
94
+ <Transition :name="transitionName" appear>
47
95
  <div
48
- class="fixed inset-0 z-[9999] bg-black/40 dark:bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
96
+ class="fixed z-[9999] bg-black/40 dark:bg-black/60 backdrop-blur-sm flex"
97
+ :class="containerClass"
49
98
  role="dialog"
50
99
  tabindex="-1"
51
100
  :aria-labelledby="`${modalId}-label`"
52
101
  @click="onBackdrop"
53
102
  >
54
103
  <div
55
- :class="['bg-card border border-card-line rounded-xl shadow-xl w-full modal-content', sizeClass]"
104
+ :class="['bg-card border border-card-line shadow-xl flex flex-col modal-panel max-h-full', panelClass]"
56
105
  @click.stop
57
106
  >
58
107
  <!-- Header -->
59
- <div v-if="showHeader" class="flex items-center justify-between px-5 py-4 border-b border-card-line">
60
- <h3 :id="`${modalId}-label`" class="text-sm font-semibold text-foreground">
108
+ <div
109
+ v-if="showHeader"
110
+ class="shrink-0 flex items-center justify-between px-5 py-4 border-b border-card-line"
111
+ >
112
+ <h3 :id="`${modalId}-label`" class="text-sm font-semibold text-foreground truncate">
61
113
  <slot name="header">{{ title }}</slot>
62
114
  </h3>
63
115
  <button
64
116
  v-if="closable"
65
117
  type="button"
66
- class="size-7 flex items-center justify-center rounded-lg text-muted-foreground hover:text-muted-foreground-1 hover:bg-muted-hover transition-colors"
118
+ class="size-7 flex items-center justify-center rounded-control text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors shrink-0"
67
119
  @click="close"
120
+ aria-label="Cerrar"
68
121
  >
69
- <svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
70
- <path d="M18 6 6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" />
71
- </svg>
122
+ <IconX class="size-4" />
72
123
  </button>
73
124
  </div>
74
125
 
75
126
  <!-- Body -->
76
- <div class="p-5">
127
+ <div
128
+ :class="[
129
+ paddingClass,
130
+ scrollBody ? 'flex-1 min-h-0 overflow-y-auto' : '',
131
+ ]"
132
+ >
77
133
  <slot />
78
134
  </div>
79
135
 
80
136
  <!-- Footer -->
81
- <div v-if="showFooter" class="flex items-center justify-end gap-2 px-5 py-4 border-t border-card-line">
137
+ <div
138
+ v-if="showFooter"
139
+ class="shrink-0 flex items-center justify-end gap-2 px-5 py-4 border-t border-card-line bg-card"
140
+ >
82
141
  <slot name="footer">
83
142
  <AppButton v-if="closable" text="Cerrar" severity="secondary" size="sm" @click="close" />
84
143
  </slot>
@@ -90,14 +149,40 @@ onUnmounted(() => {
90
149
  </template>
91
150
 
92
151
  <style scoped>
93
- .modal-enter-active,
94
- .modal-leave-active { transition: opacity 0.15s ease; }
95
- .modal-enter-from,
96
- .modal-leave-to { opacity: 0; }
97
-
98
- .modal-enter-from .modal-content,
99
- .modal-leave-to .modal-content { transform: scale(0.97) translateY(-8px); opacity: 0; }
100
- .modal-enter-to .modal-content,
101
- .modal-leave-from .modal-content { transform: scale(1) translateY(0); opacity: 1; }
102
- .modal-content { transition: transform 0.15s ease, opacity 0.15s ease; }
152
+ /* Center pop */
153
+ .modal-pop-enter-active,
154
+ .modal-pop-leave-active { transition: opacity 0.15s ease; }
155
+ .modal-pop-enter-from,
156
+ .modal-pop-leave-to { opacity: 0; }
157
+ .modal-pop-enter-from .modal-panel,
158
+ .modal-pop-leave-to .modal-panel { transform: scale(0.97) translateY(-8px); opacity: 0; }
159
+ .modal-pop-enter-to .modal-panel,
160
+ .modal-pop-leave-from .modal-panel { transform: scale(1) translateY(0); opacity: 1; }
161
+ .modal-panel { transition: transform 0.18s cubic-bezier(.2,.8,.2,1), opacity 0.18s ease; }
162
+
163
+ /* Fade fullscreen */
164
+ .modal-fade-enter-active,
165
+ .modal-fade-leave-active { transition: opacity 0.2s ease; }
166
+ .modal-fade-enter-from,
167
+ .modal-fade-leave-to { opacity: 0; }
168
+
169
+ /* Drawer right */
170
+ .modal-slide-right-enter-active,
171
+ .modal-slide-right-leave-active { transition: opacity 0.2s ease; }
172
+ .modal-slide-right-enter-from,
173
+ .modal-slide-right-leave-to { opacity: 0; }
174
+ .modal-slide-right-enter-from .modal-panel,
175
+ .modal-slide-right-leave-to .modal-panel { transform: translateX(100%); }
176
+ .modal-slide-right-enter-to .modal-panel,
177
+ .modal-slide-right-leave-from .modal-panel { transform: translateX(0); }
178
+
179
+ /* Drawer left */
180
+ .modal-slide-left-enter-active,
181
+ .modal-slide-left-leave-active { transition: opacity 0.2s ease; }
182
+ .modal-slide-left-enter-from,
183
+ .modal-slide-left-leave-to { opacity: 0; }
184
+ .modal-slide-left-enter-from .modal-panel,
185
+ .modal-slide-left-leave-to .modal-panel { transform: translateX(-100%); }
186
+ .modal-slide-left-enter-to .modal-panel,
187
+ .modal-slide-left-leave-from .modal-panel { transform: translateX(0); }
103
188
  </style>
@@ -10,12 +10,12 @@
10
10
  */
11
11
  import { IconBuildingSkyscraper, IconArrowRight } from '@tabler/icons-vue'
12
12
 
13
- const { current: currentApp } = useApp()
13
+ const { current: currentContext } = useContext()
14
14
  const { available, switchTo } = useOrganization()
15
15
 
16
16
  const title = computed(() =>
17
- currentApp.value
18
- ? `Elige una organización para continuar en ${currentApp.value.label}`
17
+ currentContext.value
18
+ ? `Elige una organización para continuar en ${currentContext.value.label}`
19
19
  : 'Elige una organización'
20
20
  )
21
21
 
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { IconSearch, IconLayoutColumns, IconGripVertical, IconX, IconPlus, IconChevronLeft, IconCheck, IconChevronDown } from '@tabler/icons-vue'
2
+ import { IconSearch, IconLayoutColumns, IconGripVertical, IconX, IconPlus, IconChevronLeft, IconCheck, IconChevronDown, IconTable, IconLayoutGrid, IconDatabase, IconLayoutSidebarRight } from '@tabler/icons-vue'
3
3
  import Table from './index.vue'
4
4
 
5
5
  const props = defineProps({
@@ -16,6 +16,12 @@ const props = defineProps({
16
16
  showSearch: { type: Boolean, default: true },
17
17
  showFilters: { type: Boolean, default: true },
18
18
  showExport: { type: Boolean, default: true },
19
+ showColumns: { type: Boolean, default: true },
20
+ /** Mostrar selector "Filas por página" en el footer. Default false. */
21
+ showPerPage: { type: Boolean, default: false },
22
+
23
+ // Tamaño visible (wrap + overflow auto). 'sm' | 'md' (default) | 'lg' | 'fit'.
24
+ size: { type: String, default: 'md' },
19
25
  filters: { type: Array, default: () => [] },
20
26
  splitRatio: { type: Number, default: 60 },
21
27
  autoClosePreview: { type: Boolean, default: true },
@@ -23,13 +29,103 @@ const props = defineProps({
23
29
  previewDeletable: { type: Boolean, default: false },
24
30
  defaultPinnedColumns: { type: Object, default: null }, // { left?: string[], right?: string[] }
25
31
  persistPreferences: { type: Boolean, default: true }, // persist column prefs in backend
32
+
33
+ // bordered=false → quita border + rounded-card del wrapper exterior
34
+ bordered: { type: Boolean, default: true },
35
+
36
+ // 'bottom' (default) | 'top' | 'none'
37
+ // 'bottom' → footer + pagination debajo
38
+ // 'top' → mismo footer + pagination, arriba (vía CSS order)
39
+ // 'none' → sin footer/pagination; reload + instant compactos junto a columns
40
+ infoPosition: { type: String, default: 'bottom' },
41
+
42
+ // View switcher
43
+ showViewSwitcher: { type: Boolean, default: false },
44
+ defaultViewType: { type: String, default: 'table' },
45
+ /** Page size when in cards view (default 12 for clean grid layouts). */
46
+ cardsPageSize: { type: Number, default: 12 },
47
+ /** Mutation function for database inline editing: async (row, colKey, value) => void */
48
+ updateMutation: { type: Function, default: null },
26
49
  })
27
50
 
28
51
  const resolvedEndpoint = computed(() => props.table?.endpoint ?? props.endpoint)
29
52
  const resolvedName = computed(() => props.table?.name ?? props.name)
30
53
 
54
+ // ─── View switcher state ──────────────────────────────────────────────────────
55
+ const STORAGE_KEY_VIEW = computed(() => `table-view-${resolvedName.value || 'default'}`)
56
+ const viewType = ref(props.defaultViewType)
57
+
58
+ const viewModes = [
59
+ { id: 'table', icon: IconTable, label: 'Tabla' },
60
+ { id: 'cards', icon: IconLayoutGrid, label: 'Tarjetas' },
61
+ { id: 'database', icon: IconDatabase, label: 'Base de datos' },
62
+ { id: 'divider', icon: IconLayoutSidebarRight, label: 'Vista dividida' },
63
+ ]
64
+
65
+ onMounted(() => {
66
+ if (resolvedName.value && props.showViewSwitcher) {
67
+ const stored = localStorage.getItem(STORAGE_KEY_VIEW.value)
68
+ if (stored && ['table', 'cards', 'database', 'divider'].includes(stored)) {
69
+ viewType.value = stored
70
+ }
71
+ }
72
+ })
73
+
74
+ watch(viewType, (newView, oldView) => {
75
+ if (resolvedName.value && props.showViewSwitcher) {
76
+ localStorage.setItem(STORAGE_KEY_VIEW.value, newView)
77
+ }
78
+ // Adjust page size when entering/leaving cards view
79
+ if (newView === 'cards') {
80
+ nextTick(() => tableRef.value?.setPageSize(props.cardsPageSize))
81
+ } else if (oldView === 'cards') {
82
+ nextTick(() => tableRef.value?.setPageSize(10))
83
+ }
84
+ })
85
+
86
+ // ─── Resizable divider ────────────────────────────────────────────────────────
87
+ const localSplitRatio = ref(props.splitRatio)
88
+ let _dividerAnimFrame = null
89
+
90
+ const startDividerDrag = (e) => {
91
+ e.preventDefault()
92
+ const startX = e.clientX
93
+ const startRatio = localSplitRatio.value
94
+ const containerWidth = containerRef.value?.getBoundingClientRect().width ?? window.innerWidth
95
+
96
+ const onMove = (ev) => {
97
+ if (_dividerAnimFrame) cancelAnimationFrame(_dividerAnimFrame)
98
+ _dividerAnimFrame = requestAnimationFrame(() => {
99
+ const dx = ev.clientX - startX
100
+ localSplitRatio.value = Math.min(80, Math.max(20, startRatio + (dx / containerWidth) * 100))
101
+ })
102
+ }
103
+
104
+ const onUp = () => {
105
+ document.removeEventListener('mousemove', onMove)
106
+ document.removeEventListener('mouseup', onUp)
107
+ document.body.style.cursor = ''
108
+ document.body.style.userSelect = ''
109
+ }
110
+
111
+ document.body.style.cursor = 'col-resize'
112
+ document.body.style.userSelect = 'none'
113
+ document.addEventListener('mousemove', onMove)
114
+ document.addEventListener('mouseup', onUp)
115
+ }
116
+
31
117
  // ─── Table preferences (column pinning, visibility, order) ───────────────────
32
118
  const tablePrefName = computed(() => resolvedName.value || 'default')
119
+
120
+ const sizeStyle = computed(() => {
121
+ switch (props.size) {
122
+ case 'sm': return { minHeight: '18rem', maxHeight: '24rem', overflow: 'auto' }
123
+ case 'lg': return { minHeight: '45rem', maxHeight: '75vh', overflow: 'auto' }
124
+ case 'fit': return {}
125
+ case 'md':
126
+ default: return {}
127
+ }
128
+ })
33
129
  const { preferences: tablePrefs, load: loadPrefs, save: savePrefs } = useTablePreferences(tablePrefName.value)
34
130
 
35
131
  // Resolved initial pinned columns: merge defaultPinnedColumns with saved preferences
@@ -562,7 +658,36 @@ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, close
562
658
 
563
659
  <!-- Secondary actions: pushed to the right, icon-only style -->
564
660
  <div class="ml-auto flex items-center gap-1">
661
+ <!-- Reload + Instant compactos cuando infoPosition === 'none' -->
662
+ <InfoToolbar
663
+ v-if="infoPosition === 'none'"
664
+ :show-reload="showReloadButton"
665
+ :is-fetching="tableRef?.isFetching ?? false"
666
+ :show-instant="tableRef?.isDataFromCache ?? false"
667
+ @reload="() => tableRef?.reload?.()"
668
+ />
669
+
670
+ <!-- View switcher -->
671
+ <div v-if="showViewSwitcher" class="flex items-center gap-0.5 p-0.5 bg-surface rounded-control border border-card-line">
672
+ <button
673
+ v-for="mode in viewModes"
674
+ :key="mode.id"
675
+ type="button"
676
+ @click="viewType = mode.id"
677
+ :title="mode.label"
678
+ :class="[
679
+ 'p-1 inline-flex items-center justify-center rounded transition-colors',
680
+ viewType === mode.id
681
+ ? 'bg-card shadow-sm text-foreground'
682
+ : 'text-muted-foreground hover:text-foreground'
683
+ ]"
684
+ >
685
+ <component :is="mode.icon" :size="14" :stroke="1.75" />
686
+ </button>
687
+ </div>
688
+
565
689
  <button
690
+ v-if="showColumns"
566
691
  ref="columnButtonRef"
567
692
  type="button"
568
693
  @click="showColumnPanel = !showColumnPanel"
@@ -608,35 +733,83 @@ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, close
608
733
  </div>
609
734
  </div>
610
735
 
611
- <!-- Tabla -->
612
- <div class="overflow-hidden border border-card-line rounded-card">
613
- <Table
614
- ref="tableRef"
615
- :endpoint="resolvedEndpoint"
616
- :columns="columns"
617
- :name="resolvedName"
618
- :params="mergedParams"
619
- :search="search"
620
- :checkable="checkable"
621
- :cached="cached"
622
- :show-reload-button="showReloadButton"
623
- :click-row-to-open="clickRowToOpen"
624
- :preview-row-id="previewRow?.id ?? null"
625
- :preview-mode="!!previewEnabled"
626
- :pinned-columns="resolvedPinnedColumns"
627
- @row-click="handleRowClick"
628
- @loaded="handleLoaded"
629
- @page-change="closePreview"
630
- @per-page-change="closePreview"
736
+ <!-- Tabla — normal or split layout -->
737
+ <div
738
+ :class="[
739
+ bordered ? 'overflow-hidden border border-card-line rounded-card' : '',
740
+ viewType === 'divider' ? 'flex' : '',
741
+ ]"
742
+ :style="viewType === 'divider' ? { ...sizeStyle, minHeight: sizeStyle.minHeight ?? '28rem' } : sizeStyle"
743
+ >
744
+ <!-- Table panel -->
745
+ <div
746
+ :class="viewType === 'divider' ? 'overflow-hidden min-w-0' : ''"
747
+ :style="viewType === 'divider' ? { width: localSplitRatio + '%', minWidth: '280px' } : {}"
748
+ >
749
+ <Table
750
+ ref="tableRef"
751
+ :endpoint="resolvedEndpoint"
752
+ :columns="columns"
753
+ :name="resolvedName"
754
+ :params="mergedParams"
755
+ :search="search"
756
+ :checkable="checkable"
757
+ :cached="cached"
758
+ :show-reload-button="showReloadButton"
759
+ :info-position="infoPosition"
760
+ :show-per-page="showPerPage"
761
+ :click-row-to-open="clickRowToOpen"
762
+ :preview-row-id="previewRow?.id ?? null"
763
+ :preview-mode="!!previewEnabled"
764
+ :pinned-columns="resolvedPinnedColumns"
765
+ :view-mode="viewType === 'divider' ? 'table' : viewType"
766
+ :update-mutation="updateMutation"
767
+ :bordered="bordered"
768
+ @row-click="handleRowClick"
769
+ @loaded="handleLoaded"
770
+ @page-change="closePreview"
771
+ @per-page-change="closePreview"
772
+ >
773
+ <template v-for="(_, name) in forwardedSlots" #[name]="slotProps">
774
+ <slot :name="name" v-bind="slotProps ?? {}" />
775
+ </template>
776
+ </Table>
777
+ </div>
778
+
779
+ <!-- Resizable drag handle (divider mode only) -->
780
+ <div
781
+ v-if="viewType === 'divider'"
782
+ class="w-1 shrink-0 bg-card-line hover:bg-primary/40 active:bg-primary/60 cursor-col-resize transition-colors relative group select-none"
783
+ @mousedown="startDividerDrag"
631
784
  >
632
- <template v-for="(_, name) in forwardedSlots" #[name]="slotProps">
633
- <slot :name="name" v-bind="slotProps ?? {}" />
785
+ <!-- Visual pill indicator -->
786
+ <div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-8 rounded-full bg-card-line group-hover:bg-primary/50 transition-colors" />
787
+ </div>
788
+
789
+ <!-- Preview panel (divider mode only) -->
790
+ <div v-if="viewType === 'divider'" class="flex-1 min-w-0 overflow-auto">
791
+ <template v-if="previewRow">
792
+ <div class="flex items-center justify-between px-4 py-3 border-b border-card-line">
793
+ <slot name="preview-header" :row="previewRow" :close="closePreview" />
794
+ <button
795
+ @click="closePreview"
796
+ class="p-1 rounded-control text-muted-foreground hover:text-foreground hover:bg-muted-hover ml-auto"
797
+ >
798
+ <IconX :size="16" />
799
+ </button>
800
+ </div>
801
+ <slot name="preview" :row="previewRow" :close="closePreview" />
634
802
  </template>
635
- </Table>
803
+ <div v-else class="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground py-16">
804
+ <IconLayoutSidebarRight :size="32" :stroke="1.25" class="opacity-30" />
805
+ <p class="text-sm">Seleccioná un elemento para ver el detalle</p>
806
+ </div>
807
+ </div>
636
808
  </div>
637
809
 
638
- <!-- Preview compartido (panel overlay + chip flotante + history tab) -->
810
+ <!-- Preview compartido (panel overlay + chip flotante + history tab) — only when NOT in divider mode -->
639
811
  <DataPreview
812
+ v-if="viewType !== 'divider'"
640
813
  ref="dataPreviewRef"
641
814
  v-model:row="previewRow"
642
815
  :enabled="previewEnabled"