@innertia-solutions/nuxt-theme-spark 0.1.141 → 0.1.143

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.
@@ -6,6 +6,7 @@ import {
6
6
  IconChevronDown,
7
7
  IconReload,
8
8
  IconBolt,
9
+ IconPin,
9
10
  } from '@tabler/icons-vue'
10
11
 
11
12
  const props = defineProps({
@@ -22,6 +23,7 @@ const props = defineProps({
22
23
  clickRowToOpen: { type: Boolean, default: false },
23
24
  previewRowId: { type: [String, Number], default: null },
24
25
  previewMode: { type: Boolean, default: false },
26
+ pinnedColumns: { type: Object, default: null }, // { left?: string[], right?: string[] }
25
27
  })
26
28
 
27
29
  const emit = defineEmits(['update:search', 'row-click', 'loaded', 'page-change', 'per-page-change'])
@@ -55,6 +57,7 @@ const columnOrder = ref([])
55
57
  const columnSizing = ref({})
56
58
  const columnSizingInfo = ref({})
57
59
  const rowSelection = ref({})
60
+ const columnPinning = ref({ left: [], right: [] })
58
61
  const isCustomPerPage = ref(false)
59
62
 
60
63
  const makeUpdater = (stateRef) => (updater) => {
@@ -111,6 +114,7 @@ const table = useVueTable({
111
114
  get columnSizing() { return columnSizing.value },
112
115
  get columnSizingInfo() { return columnSizingInfo.value },
113
116
  get rowSelection() { return rowSelection.value },
117
+ get columnPinning() { return columnPinning.value },
114
118
  },
115
119
  onPaginationChange: makeUpdater(pagination),
116
120
  onSortingChange: makeUpdater(sorting),
@@ -120,9 +124,11 @@ const table = useVueTable({
120
124
  onColumnSizingChange: makeUpdater(columnSizing),
121
125
  onColumnSizingInfoChange: makeUpdater(columnSizingInfo),
122
126
  onRowSelectionChange: makeUpdater(rowSelection),
127
+ onColumnPinningChange: makeUpdater(columnPinning),
123
128
  getCoreRowModel: getCoreRowModel(),
124
129
  columnResizeMode: 'onChange',
125
130
  enableColumnResizing: true,
131
+ enableColumnPinning: true,
126
132
  manualPagination: true,
127
133
  manualSorting: true,
128
134
  manualFiltering: true,
@@ -131,6 +137,58 @@ const table = useVueTable({
131
137
  enableRowSelection: true,
132
138
  })
133
139
 
140
+ // ─── Column pinning helpers ───────────────────────────────────────────────────
141
+ const pinColumn = (key, position) => {
142
+ table.getColumn(key)?.pin(position)
143
+ }
144
+
145
+ const getPinnedStyles = (column, isHeader = false) => {
146
+ const pinned = column.getIsPinned()
147
+ if (!pinned) return {}
148
+ const z = isHeader ? 2 : 1
149
+ const w = column.getSize() + 'px'
150
+ // Headers always use the solid card background.
151
+ // Body cells use --row-bg, which is set to solid colors only (normal + hover) via <style scoped>.
152
+ // Selected/preview rows intentionally don't set --row-bg so sticky cells fall back to --card,
153
+ // which prevents semi-transparent tints from bleeding through the sticky cell.
154
+ const bg = isHeader ? 'var(--card, #fff)' : 'var(--row-bg, var(--card, #fff))'
155
+ const base = {
156
+ position: 'sticky',
157
+ zIndex: z,
158
+ background: bg,
159
+ width: w,
160
+ minWidth: w,
161
+ maxWidth: w,
162
+ }
163
+ // inset box-shadow: paints inside the cell so it can't be covered by adjacent cells
164
+ // and always follows the visual sticky position (unlike border or outset box-shadow)
165
+ if (pinned === 'left') return { ...base, left: column.getStart('left') + 'px', boxShadow: 'inset -1px 0 0 0 var(--card-line, #e5e7eb)' }
166
+ if (pinned === 'right') return { ...base, right: column.getAfter('right') + 'px', boxShadow: 'inset 1px 0 0 0 var(--card-line, #e5e7eb)' }
167
+ return {}
168
+ }
169
+
170
+
171
+ // Initialize pinning from prop if provided
172
+ onMounted(() => {
173
+ if (props.pinnedColumns) {
174
+ columnPinning.value = {
175
+ left: props.pinnedColumns.left ?? [],
176
+ right: props.pinnedColumns.right ?? [],
177
+ }
178
+ }
179
+ })
180
+
181
+ // Keep 'select' always first in the left pinning array.
182
+ // Fires synchronously so the colgroup/headers never render in wrong order.
183
+ watch(() => columnPinning.value.left, (left) => {
184
+ if (props.checkable && left.includes('select') && left[0] !== 'select') {
185
+ columnPinning.value = {
186
+ ...columnPinning.value,
187
+ left: ['select', ...left.filter(id => id !== 'select')],
188
+ }
189
+ }
190
+ }, { flush: 'sync' })
191
+
134
192
  // ─── Fetch ────────────────────────────────────────────────────────────────────
135
193
  const buildRequestParams = () => {
136
194
  const { sort, ...otherParams } = props.params
@@ -457,6 +515,23 @@ const handleRowKeydown = (row, e) => {
457
515
  emit('row-click', row.original, e)
458
516
  }
459
517
 
518
+ // Compute --row-bg for pinned (sticky) cells.
519
+ // Non-pinned cells get background from Tailwind classes on <tr>.
520
+ // Pinned cells inherit --row-bg which must be a solid opaque color (no transparency → no bleed-through).
521
+ // color-mix() blends the Tailwind tint with the card color to produce an opaque equivalent.
522
+ // For normal/hover rows the <style scoped> CSS rule handles it; selected/preview override via inline style.
523
+ const pinnedRowStyle = (row) => {
524
+ if (props.previewRowId && row.original.id === props.previewRowId) {
525
+ // !bg-indigo-50 → solid indigo-50
526
+ return { '--row-bg': 'color-mix(in srgb, #eef2ff 100%, var(--card, #fff))' }
527
+ }
528
+ if (row.getIsSelected()) {
529
+ // bg-indigo-50/40 → 40% indigo-50 blended with card
530
+ return { '--row-bg': 'color-mix(in srgb, #eef2ff 40%, var(--card, #fff))' }
531
+ }
532
+ return {}
533
+ }
534
+
460
535
  // ─── Expose ───────────────────────────────────────────────────────────────────
461
536
  const reloadTable = () => {
462
537
  clearCache()
@@ -475,6 +550,8 @@ defineExpose({
475
550
  isDataFromCache,
476
551
  cached: computed(() => props.cached),
477
552
  paginationBarRef,
553
+ columnPinning,
554
+ pinColumn,
478
555
  })
479
556
  </script>
480
557
 
@@ -488,8 +565,9 @@ defineExpose({
488
565
  :style="{ tableLayout: 'fixed', width: table.getTotalSize() + 'px', minWidth: '100%' }"
489
566
  >
490
567
  <colgroup>
568
+ <!-- Must use pinning order (left|center|right) — same as getHeaderGroups() and row.getVisibleCells() -->
491
569
  <col
492
- v-for="col in table.getVisibleLeafColumns()"
570
+ v-for="col in [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns(), ...table.getRightVisibleLeafColumns()]"
493
571
  :key="col.id"
494
572
  :style="{ width: col.getSize() + 'px' }"
495
573
  >
@@ -497,24 +575,23 @@ defineExpose({
497
575
  <thead class="relative z-20 bg-card">
498
576
  <template v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
499
577
  <!-- Main header row -->
500
- <tr
501
- class="divide-x divide-card-line"
502
- >
578
+ <tr class="bg-card">
503
579
  <th
504
580
  v-for="header in headerGroup.headers"
505
581
  :key="header.id"
506
582
  scope="col"
507
- :draggable="header.id !== 'select' && resizeHoverId !== header.id"
508
- @dragstart="header.id !== 'select' && resizeHoverId !== header.id && onHeaderDragStart(header.id)"
583
+ :draggable="header.id !== 'select' && resizeHoverId !== header.id && !header.column.getIsPinned()"
584
+ @dragstart="header.id !== 'select' && resizeHoverId !== header.id && !header.column.getIsPinned() && onHeaderDragStart(header.id)"
509
585
  @dragover="header.id !== 'select' && onHeaderDragOver($event, header.id)"
510
586
  @dragleave="onHeaderDragLeave"
511
587
  @drop="header.id !== 'select' && onHeaderDrop(header.id)"
512
- class="relative overflow-hidden"
588
+ class="relative"
513
589
  :class="[
514
590
  header.id === 'select' ? 'text-center' : '',
515
591
  dragOverHeaderId === header.id ? 'bg-indigo-50 dark:bg-indigo-900/20' : '',
516
592
  header.column.getCanSort() ? 'cursor-pointer select-none' : '',
517
593
  ]"
594
+ :style="getPinnedStyles(header.column, true)"
518
595
  @click="header.column.getCanSort() && header.column.toggleSorting()"
519
596
  >
520
597
  <!-- Select all checkbox -->
@@ -529,8 +606,12 @@ defineExpose({
529
606
  </template>
530
607
  <!-- Regular column header -->
531
608
  <template v-else>
532
- <div class="px-4 py-3 flex items-center gap-x-1 text-xs font-medium text-muted-foreground w-full">
533
- {{ header.column.columnDef.meta?.label ?? header.id }}
609
+ <div
610
+ class="px-4 py-3 flex items-center gap-x-1 text-xs font-medium w-full overflow-hidden"
611
+ :class="header.column.getIsPinned() ? 'text-foreground' : 'text-muted-foreground'"
612
+ >
613
+ <IconPin v-if="header.column.getIsPinned()" class="size-3 shrink-0 text-indigo-400 dark:text-indigo-500" />
614
+ <span class="truncate">{{ header.column.columnDef.meta?.label ?? header.id }}</span>
534
615
  <span v-if="header.column.getCanSort()">
535
616
  <IconSelector v-if="!header.column.getIsSorted()" class="size-4 opacity-40" />
536
617
  <IconChevronDown v-else-if="header.column.getIsSorted() === 'desc'" class="size-4" />
@@ -563,12 +644,15 @@ defineExpose({
563
644
  <!-- Column filter row -->
564
645
  <tr
565
646
  v-if="hasFilterableColumns"
566
- class="divide-x divide-card-line border-b border-card-line bg-muted/50"
647
+ class="border-b border-card-line bg-muted/50"
567
648
  >
568
649
  <th
569
650
  v-for="header in headerGroup.headers"
570
651
  :key="'f-' + header.id"
571
- :class="header.id === 'select' ? 'w-12' : 'px-3 py-1.5'"
652
+ :class="[
653
+ header.id === 'select' ? 'w-12' : 'px-3 py-1.5',
654
+ ]"
655
+ :style="getPinnedStyles(header.column, true)"
572
656
  >
573
657
  <input
574
658
  v-if="header.column.getCanFilter()"
@@ -588,13 +672,15 @@ defineExpose({
588
672
  v-if="loading"
589
673
  v-for="(_, i) in skeletonRows"
590
674
  :key="'sk-' + i"
591
- class="animate-pulse divide-x divide-card-line bg-card"
675
+ class="animate-pulse bg-card"
592
676
  >
593
677
  <td
594
678
  v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
595
679
  :key="'skc-' + header.id"
596
- :class="header.id === 'select' ? 'text-center w-12' : 'px-4'"
597
- :style="{ height: lastRowHeight + 'px' }"
680
+ :class="[
681
+ header.id === 'select' ? 'text-center w-12' : 'px-4 overflow-hidden',
682
+ ]"
683
+ :style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
598
684
  >
599
685
  <div v-if="header.id === 'select'" class="w-4 h-4 bg-surface-1 rounded mx-auto"></div>
600
686
  <div v-else class="h-4 w-[50%] rounded bg-surface-1"></div>
@@ -606,12 +692,12 @@ defineExpose({
606
692
  v-if="loading && skeletonRows.length < pagination.pageSize"
607
693
  v-for="i in (pagination.pageSize - skeletonRows.length)"
608
694
  :key="'lf-' + i"
609
- class="divide-x divide-card-line bg-card"
695
+ class="bg-card"
610
696
  >
611
697
  <td
612
698
  v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
613
699
  :key="'lfc-' + header.id"
614
- :style="{ height: lastRowHeight + 'px' }"
700
+ :style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
615
701
  />
616
702
  </tr>
617
703
 
@@ -620,12 +706,12 @@ defineExpose({
620
706
  v-if="!loading && tableData.length === 0"
621
707
  v-for="i in pagination.pageSize"
622
708
  :key="'esk-' + i"
623
- class="divide-x divide-card-line bg-card"
709
+ class="bg-card"
624
710
  >
625
711
  <td
626
712
  v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
627
713
  :key="'eskc-' + header.id"
628
- :style="{ height: lastRowHeight + 'px' }"
714
+ :style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
629
715
  />
630
716
  </tr>
631
717
 
@@ -638,12 +724,13 @@ defineExpose({
638
724
  @click="(e) => handleRowClick(row, e)"
639
725
  @keydown="(e) => handleRowKeydown(row, e)"
640
726
  :tabindex="isRowClickEnabled ? 0 : undefined"
641
- class="divide-x divide-card-line bg-card hover:bg-layer-hover transition-colors"
727
+ class="bg-card hover:bg-layer-hover transition-colors"
642
728
  :class="{
643
729
  'cursor-pointer': isRowClickEnabled,
644
730
  'bg-indigo-50/40 dark:bg-indigo-900/10 hover:bg-indigo-50/60': row.getIsSelected(),
645
731
  '!bg-indigo-50 dark:!bg-indigo-900/20 ring-1 ring-inset ring-indigo-200 dark:ring-indigo-700': previewRowId && row.original.id === previewRowId,
646
732
  }"
733
+ :style="pinnedRowStyle(row)"
647
734
  >
648
735
  <td
649
736
  v-for="cell in row.getVisibleCells()"
@@ -651,10 +738,11 @@ defineExpose({
651
738
  :data-col-id="cell.column.id"
652
739
  :class="[
653
740
  cell.column.id === 'select'
654
- ? 'text-center w-12'
655
- : 'px-4 py-3 text-sm text-muted-foreground-1',
741
+ ? 'text-center w-12 overflow-hidden'
742
+ : 'px-4 py-3 text-sm text-muted-foreground-1 overflow-hidden',
656
743
  cell.column.id !== 'select' ? cell.column.columnDef.meta?.class ?? '' : '',
657
744
  ]"
745
+ :style="getPinnedStyles(cell.column)"
658
746
  >
659
747
  <!-- Select checkbox -->
660
748
  <template v-if="cell.column.id === 'select'">
@@ -682,12 +770,12 @@ defineExpose({
682
770
  v-if="!loading && tableData.length > 0 && tableData.length < pagination.pageSize"
683
771
  v-for="i in (pagination.pageSize - tableData.length)"
684
772
  :key="'fill-' + i"
685
- class="divide-x divide-card-line bg-card"
773
+ class="bg-card"
686
774
  >
687
775
  <td
688
776
  v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
689
777
  :key="'fillc-' + header.id"
690
- :style="{ height: lastRowHeight + 'px' }"
778
+ :style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
691
779
  />
692
780
  </tr>
693
781
  </tbody>
@@ -870,3 +958,17 @@ defineExpose({
870
958
  </div>
871
959
  </div>
872
960
  </template>
961
+
962
+ <style scoped>
963
+ /* --row-bg drives the background of sticky (pinned) body cells.
964
+ Only solid, opaque values here — semi-transparent tints for selected/preview
965
+ rows intentionally do NOT override --row-bg, so pinned cells stay opaque
966
+ and text from scrolling content can't bleed through. */
967
+ tbody tr {
968
+ --row-bg: var(--card, #fff);
969
+ }
970
+ tbody tr:hover {
971
+ --row-bg: var(--layer-hover, #f8fafc);
972
+ }
973
+ </style>
974
+
@@ -26,8 +26,8 @@ const toggleColumn = (key) => {
26
26
  if (idx >= 0) selectedColumns.value.splice(idx, 1)
27
27
  else selectedColumns.value.push(key)
28
28
  }
29
- const allSelected = computed(() => selectedColumns.value.length === props.columns.length)
30
- const toggleAll = () => {
29
+ const allSelected = computed(() => selectedColumns.value.length === props.columns.length)
30
+ const toggleAll = () => {
31
31
  selectedColumns.value = allSelected.value ? [] : props.columns.map(c => c.key)
32
32
  }
33
33
 
@@ -43,12 +43,12 @@ const doExport = () => {
43
43
  isOpen.value = false
44
44
  }
45
45
 
46
- const panelRef = ref(null)
46
+ const panelRef = ref(null)
47
47
  const triggerRef = ref(null)
48
48
 
49
49
  const onOutsideClick = (e) => {
50
50
  if (
51
- panelRef.value && !panelRef.value.contains(e.target) &&
51
+ panelRef.value && !panelRef.value.contains(e.target) &&
52
52
  triggerRef.value && !triggerRef.value.contains(e.target)
53
53
  ) {
54
54
  isOpen.value = false
@@ -65,19 +65,21 @@ defineExpose({ open: () => { isOpen.value = true } })
65
65
 
66
66
  <template>
67
67
  <div class="relative">
68
+
69
+ <!-- Trigger — icon-only, igual al botón de columnas -->
68
70
  <button
69
71
  ref="triggerRef"
70
72
  type="button"
71
73
  @click="isOpen = !isOpen"
74
+ title="Exportar"
72
75
  :class="[
73
- 'py-1.5 px-3 inline-flex items-center gap-2 text-sm font-medium rounded-lg border transition-colors',
76
+ 'p-1.5 inline-flex items-center justify-center rounded-lg border transition-colors',
74
77
  isOpen
75
- ? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:border-blue-500 dark:text-blue-300'
76
- : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
78
+ ? 'border-primary/40 bg-primary/10 text-primary'
79
+ : 'border-transparent text-muted-foreground hover:border-card-line hover:bg-muted-hover hover:text-foreground'
77
80
  ]"
78
81
  >
79
82
  <IconDownload class="size-4" stroke="1.5" />
80
- Exportar
81
83
  </button>
82
84
 
83
85
  <Transition
@@ -105,7 +107,7 @@ defineExpose({ open: () => { isOpen.value = true } })
105
107
  :class="[
106
108
  'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors',
107
109
  format === f.value
108
- ? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:border-blue-500 dark:text-blue-300'
110
+ ? 'border-primary/40 bg-primary/10 text-primary'
109
111
  : 'border-card-line text-muted-foreground-1 hover:bg-muted-hover'
110
112
  ]"
111
113
  >
@@ -124,7 +126,7 @@ defineExpose({ open: () => { isOpen.value = true } })
124
126
  <input
125
127
  v-model="filename"
126
128
  type="text"
127
- class="flex-1 rounded-lg border border-card-line bg-card text-foreground py-1.5 px-2.5 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 min-w-0"
129
+ class="flex-1 rounded-lg border border-card-line bg-card text-foreground py-1.5 px-2.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 min-w-0"
128
130
  />
129
131
  <span class="text-xs text-muted-foreground shrink-0">.{{ format }}</span>
130
132
  </div>
@@ -134,7 +136,7 @@ defineExpose({ open: () => { isOpen.value = true } })
134
136
  <div v-if="columns.length > 0" class="mb-3 px-1">
135
137
  <div class="flex items-center justify-between mb-1.5">
136
138
  <label class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">Columnas</label>
137
- <button type="button" @click="toggleAll" class="text-[10px] text-blue-600 dark:text-blue-400 hover:underline">
139
+ <button type="button" @click="toggleAll" class="text-[10px] text-primary hover:underline">
138
140
  {{ allSelected ? 'Ninguna' : 'Todas' }}
139
141
  </button>
140
142
  </div>
@@ -148,7 +150,7 @@ defineExpose({ open: () => { isOpen.value = true } })
148
150
  type="checkbox"
149
151
  :checked="selectedColumns.includes(col.key)"
150
152
  @change="toggleColumn(col.key)"
151
- class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
153
+ class="rounded border-card-line bg-surface shrink-0 cursor-pointer text-primary"
152
154
  />
153
155
  <span class="text-xs text-foreground truncate">{{ col.label }}</span>
154
156
  </label>
@@ -159,7 +161,7 @@ defineExpose({ open: () => { isOpen.value = true } })
159
161
  <button
160
162
  type="button"
161
163
  @click="doExport"
162
- class="w-full py-1.5 px-3 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors inline-flex items-center justify-center gap-2"
164
+ class="w-full py-1.5 px-3 rounded-lg bg-primary hover:bg-primary/90 text-white text-sm font-medium transition-colors inline-flex items-center justify-center gap-2"
163
165
  >
164
166
  <IconDownload class="size-4" stroke="1.5" />
165
167
  Exportar
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/nuxt-theme-spark",
3
- "version": "0.1.141",
3
+ "version": "0.1.143",
4
4
  "description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -0,0 +1,33 @@
1
+ import { useDebounceFn } from '@vueuse/core'
2
+
3
+ export interface TablePreferences {
4
+ pinning?: { left: string[], right: string[] }
5
+ visibility?: Record<string, boolean>
6
+ order?: string[]
7
+ }
8
+
9
+ export function useTablePreferences(tableName: string) {
10
+ const api = useApi()
11
+ const prefKey = `table:${tableName}:columns`
12
+
13
+ const preferences = ref<TablePreferences>({})
14
+
15
+ const load = async () => {
16
+ try {
17
+ const data = await api.get(`auth/me/preferences/${prefKey}`)
18
+ preferences.value = data?.value ?? {}
19
+ } catch {
20
+ preferences.value = {}
21
+ }
22
+ }
23
+
24
+ const save = useDebounceFn(async (value: TablePreferences) => {
25
+ try {
26
+ await api.put(`auth/me/preferences/${prefKey}`, { value, cast: 'json' })
27
+ } catch {
28
+ // silent fail — preferencias no son críticas
29
+ }
30
+ }, 800)
31
+
32
+ return { preferences, load, save }
33
+ }