feat: draggable datatable tablehead

This commit is contained in:
pa
2026-03-04 22:50:14 +09:00
parent 4df94478ba
commit 1decec4c69
4 changed files with 365 additions and 17 deletions
+116 -16
View File
@@ -12,23 +12,57 @@
</colgroup>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="getHeaderClass(header)"
:style="getPinnedStyle(header.column)">
<template v-if="!header.isPlaceholder">
<FlexRender :render="header.column.columnDef.header" :props="header.getContext()" />
<div
v-if="header.column.getCanResize?.()"
class="absolute right-0 top-0 h-full w-2 cursor-col-resize touch-none select-none opacity-0 transition-opacity group-hover:opacity-100"
@mousedown.stop="header.getResizeHandler?.()($event)"
@touchstart.stop="header.getResizeHandler?.()($event)">
<template v-if="enableColumnReorder">
<DragDropProvider @dragEnd="onHeaderDragEnd">
<template v-for="(header, hIdx) in headerGroup.headers" :key="header.id">
<SortableTableHead
v-if="isReorderable(header)"
:header="header"
:index="reorderableIndex(headerGroup.headers, hIdx)"
:header-class="getHeaderClass(header)"
:pinned-style="getPinnedStyle(header.column)" />
<TableHead
v-else
:class="getHeaderClass(header)"
:style="getPinnedStyle(header.column)">
<template v-if="!header.isPlaceholder">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()" />
<div
v-if="header.column.getCanResize?.()"
class="absolute right-0 top-0 h-full w-2 cursor-col-resize touch-none select-none opacity-0 transition-opacity group-hover:opacity-100"
@mousedown.stop="header.getResizeHandler?.()($event)"
@touchstart.stop="header.getResizeHandler?.()($event)">
<div
class="absolute right-0 top-0 h-full w-px bg-border dark:bg-border dark:brightness-[2]" />
</div>
</template>
</TableHead>
</template>
</DragDropProvider>
</template>
<template v-else>
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="getHeaderClass(header)"
:style="getPinnedStyle(header.column)">
<template v-if="!header.isPlaceholder">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()" />
<div
class="absolute right-0 top-0 h-full w-px bg-border dark:bg-border dark:brightness-[2]" />
</div>
</template>
</TableHead>
v-if="header.column.getCanResize?.()"
class="absolute right-0 top-0 h-full w-2 cursor-col-resize touch-none select-none opacity-0 transition-opacity group-hover:opacity-100"
@mousedown.stop="header.getResizeHandler?.()($event)"
@touchstart.stop="header.getResizeHandler?.()($event)">
<div
class="absolute right-0 top-0 h-full w-px bg-border dark:bg-border dark:brightness-[2]" />
</div>
</template>
</TableHead>
</template>
</TableRow>
</TableHeader>
<TableBody>
@@ -144,8 +178,10 @@
<script setup>
import { computed, nextTick, ref, watch } from 'vue';
import { DragDropProvider } from '@dnd-kit/vue';
import { FlexRender } from '@tanstack/vue-table';
import { Spinner } from '@/components/ui/spinner';
import { isSortable } from '@dnd-kit/vue/sortable';
import { storeToRefs } from 'pinia';
import { useAppearanceSettingsStore } from '@/stores/';
import { useI18n } from 'vue-i18n';
@@ -163,6 +199,7 @@
import { ContextMenu, ContextMenuTrigger } from '../context-menu';
import DataTableEmpty from './DataTableEmpty.vue';
import SortableTableHead from './SortableTableHead.vue';
const appearanceSettingsStore = useAppearanceSettingsStore();
const { isDataTableStriped } = storeToRefs(appearanceSettingsStore);
@@ -215,6 +252,10 @@
rowClass: {
type: Function,
default: null
},
enableColumnReorder: {
type: Boolean,
default: true
}
});
@@ -289,6 +330,65 @@
return !!col?.columnDef?.meta?.stretch;
};
const isReorderable = (header) => {
const col = header?.column;
if (!col) return false;
if (isSpacer(col)) return false;
if (getPinnedState(col)) return false;
if (col.columnDef?.meta?.disableReorder) return false;
return true;
};
const reorderableIndex = (headers, actualIndex) => {
let sortableIdx = 0;
for (let i = 0; i < actualIndex; i++) {
if (isReorderable(headers[i])) {
sortableIdx++;
}
}
return sortableIdx;
};
const onHeaderDragEnd = (event) => {
if (event.canceled) return;
const { source } = event.operation;
if (!isSortable(source)) return;
const { initialIndex, index } = source;
if (initialIndex === index) return;
const allColumns = props.table.getVisibleLeafColumns?.() ?? [];
const reorderableIds = allColumns
.filter((col) => {
if (isSpacer(col)) return false;
if (getPinnedState(col)) return false;
if (col.columnDef?.meta?.disableReorder) return false;
return true;
})
.map((col) => col.id);
const newOrder = [...reorderableIds];
const [moved] = newOrder.splice(initialIndex, 1);
newOrder.splice(index, 0, moved);
const fixedBefore = [];
const fixedAfter = [];
for (const col of allColumns) {
if (!reorderableIds.includes(col.id)) {
const colIdx = allColumns.indexOf(col);
const firstReorderableIdx = allColumns.findIndex((c) => reorderableIds.includes(c.id));
if (colIdx < firstReorderableIdx || firstReorderableIdx === -1) {
fixedBefore.push(col.id);
} else {
fixedAfter.push(col.id);
}
}
}
const fullOrder = [...fixedBefore, ...newOrder, ...fixedAfter];
props.table.setColumnOrder(fullOrder);
};
const getColStyle = (col) => {
if (isSpacer(col)) return { width: '0px' };
@@ -0,0 +1,63 @@
<script setup>
import { computed, ref } from 'vue';
import { FlexRender } from '@tanstack/vue-table';
import { GripVertical } from 'lucide-vue-next';
import { useSortable } from '@dnd-kit/vue/sortable';
import { TableHead } from '../table';
const props = defineProps({
header: {
type: Object,
required: true
},
index: {
type: Number,
required: true
},
headerClass: {
type: String,
default: ''
},
pinnedStyle: {
type: Object,
default: null
},
disabled: {
type: Boolean,
default: false
}
});
const element = ref(null);
const { isDragSource } = useSortable({
id: computed(() => props.header.id),
index: computed(() => props.index),
element,
disabled: computed(() => props.disabled)
});
</script>
<template>
<TableHead
ref="element"
:class="[headerClass, isDragSource && 'opacity-50', !disabled && 'cursor-grab active:cursor-grabbing']"
:style="pinnedStyle">
<template v-if="!header.isPlaceholder">
<div class="flex items-center">
<GripVertical
v-if="!disabled"
class="size-3.5 shrink-0 text-muted-foreground/50 -ml-1 mr-0.5 opacity-0 group-hover:opacity-100 transition-opacity" />
<FlexRender :render="header.column.columnDef.header" :props="header.getContext()" />
</div>
<div
v-if="header.column.getCanResize?.()"
class="absolute right-0 top-0 h-full w-2 cursor-col-resize touch-none select-none opacity-0 transition-opacity group-hover:opacity-100"
@mousedown.stop="header.getResizeHandler?.()($event)"
@touchstart.stop="header.getResizeHandler?.()($event)">
<div class="absolute right-0 top-0 h-full w-px bg-border dark:bg-border dark:brightness-[2]" />
</div>
</template>
</TableHead>
</template>
@@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it } from 'vitest';
import { useVrcxVueTable } from '../useVrcxVueTable';
/**
*
* @param {...any} ids
*/
function makeColumns(...ids) {
return ids.map((id) => ({ id, header: id, accessorKey: id }));
}
@@ -123,4 +127,75 @@ describe('useVrcxVueTable persistence', () => {
expect(sorting.value).toEqual([{ id: 'date', desc: true }]);
});
it('persists columnOrder to localStorage when order changes', async () => {
const { columnOrder } = useVrcxVueTable({
data: [],
columns: makeColumns('name', 'date', 'status'),
persistKey: 'test-col-order'
});
columnOrder.value = ['date', 'name', 'status'];
await new Promise((r) => setTimeout(r, 300));
const stored = JSON.parse(
localStorage.getItem('vrcx:table:test-col-order')
);
expect(stored).toBeTruthy();
expect(stored.columnOrder).toEqual(['date', 'name', 'status']);
});
it('restores persisted columnOrder on init', () => {
localStorage.setItem(
'vrcx:table:test-restore-order',
JSON.stringify({ columnOrder: ['status', 'name', 'date'] })
);
const { columnOrder } = useVrcxVueTable({
data: [],
columns: makeColumns('name', 'date', 'status'),
persistKey: 'test-restore-order'
});
expect(columnOrder.value).toEqual(['status', 'name', 'date']);
});
it('filters stale columnOrder entries on persist', async () => {
const { columnOrder } = useVrcxVueTable({
data: [],
columns: makeColumns('name', 'date'),
persistKey: 'test-stale-order'
});
// Include a column ID that doesn't exist
columnOrder.value = ['removed_col', 'date', 'name'];
await new Promise((r) => setTimeout(r, 300));
const stored = JSON.parse(
localStorage.getItem('vrcx:table:test-stale-order')
);
expect(stored).toBeTruthy();
// 'removed_col' should be filtered out
expect(stored.columnOrder).toEqual(['date', 'name']);
});
it('does not persist columnOrder when persistColumnOrder is false', async () => {
const { columnOrder } = useVrcxVueTable({
data: [],
columns: makeColumns('name', 'date'),
persistKey: 'test-no-persist-order',
persistColumnOrder: false
});
columnOrder.value = ['date', 'name'];
await new Promise((r) => setTimeout(r, 300));
const stored = JSON.parse(
localStorage.getItem('vrcx:table:test-no-persist-order')
);
expect(stored?.columnOrder).toBeUndefined();
});
});
+111 -1
View File
@@ -9,6 +9,10 @@ import {
} from '@tanstack/vue-table';
import { computed, ref, unref, watch } from 'vue';
/**
*
* @param str
*/
function safeJsonParse(str) {
if (!str) {
return null;
@@ -20,6 +24,11 @@ function safeJsonParse(str) {
}
}
/**
*
* @param fn
* @param wait
*/
function debounce(fn, wait) {
let t = 0;
return (...args) => {
@@ -30,6 +39,11 @@ function debounce(fn, wait) {
};
}
/**
*
* @param sizing
* @param columns
*/
function filterSizingByColumns(sizing, columns) {
if (!sizing || typeof sizing !== 'object') {
return {};
@@ -44,6 +58,11 @@ function filterSizingByColumns(sizing, columns) {
return out;
}
/**
*
* @param sorting
* @param columns
*/
function filterSortingByColumns(sorting, columns) {
if (!Array.isArray(sorting)) {
return [];
@@ -52,10 +71,31 @@ function filterSortingByColumns(sorting, columns) {
return sorting.filter((s) => s && ids.has(s.id));
}
/**
*
* @param order
* @param columns
*/
function filterOrderByColumns(order, columns) {
if (!Array.isArray(order)) {
return [];
}
const ids = new Set((columns ?? []).map((c) => c?.id).filter(Boolean));
return order.filter((id) => ids.has(id));
}
/**
*
* @param col
*/
function getColumnId(col) {
return col?.id ?? col?.accessorKey ?? null;
}
/**
*
* @param columns
*/
function findStretchColumnId(columns) {
if (!Array.isArray(columns)) {
return null;
@@ -68,16 +108,32 @@ function findStretchColumnId(columns) {
return null;
}
/**
*
* @param updaterOrValue
* @param targetRef
*/
function setRef(updaterOrValue, targetRef) {
targetRef.value = isFunction(updaterOrValue)
? updaterOrValue(targetRef.value)
: updaterOrValue;
}
/**
*
* @param func
*/
function resolveMaybeGetter(func) {
return typeof func === 'function' ? func() : unref(func);
}
/**
*
* @param columns
* @param enabled
* @param spacerId
* @param stretchAfterId
*/
function withSpacerColumn(columns, enabled, spacerId, stretchAfterId) {
if (!enabled) {
return columns;
@@ -116,6 +172,10 @@ function withSpacerColumn(columns, enabled, spacerId, stretchAfterId) {
return [...columns, spacerColumn];
}
/**
*
* @param options
*/
export function useVrcxVueTable(options) {
const {
getRowId,
@@ -137,12 +197,16 @@ export function useVrcxVueTable(options) {
columnResizeMode = 'onChange',
initialColumnSizing,
enableColumnReorder = true,
initialColumnOrder,
fillRemainingSpace = true,
spacerColumnId = '__spacer',
persistKey,
persistColumnSizing = true,
persistSorting = true,
persistColumnOrder = true,
persistDebounceMs = 200,
tableOptions = {}
@@ -157,9 +221,13 @@ export function useVrcxVueTable(options) {
const pagination = ref(initialPagination ?? { pageIndex: 0, pageSize: 50 });
const columnPinning = ref(initialColumnPinning ?? { left: [], right: [] });
const columnSizing = ref(initialColumnSizing ?? {});
const columnOrder = ref(initialColumnOrder ?? []);
const storageKey = persistKey ? `vrcx:table:${persistKey}` : null;
/**
*
*/
function readPersisted() {
if (!storageKey) {
return null;
@@ -167,6 +235,10 @@ export function useVrcxVueTable(options) {
return safeJsonParse(localStorage.getItem(storageKey));
}
/**
*
* @param patch
*/
function writePersisted(patch) {
if (!storageKey) {
return;
@@ -188,11 +260,28 @@ export function useVrcxVueTable(options) {
columnSizing.value = persisted.columnSizing;
}
if (
persisted &&
persistColumnOrder &&
Array.isArray(persisted.columnOrder)
) {
columnOrder.value = persisted.columnOrder;
}
const state = {};
const handlers = {};
const rowModels = {};
const extra = {};
/**
*
* @param enabled
* @param key
* @param r
* @param onChangeKey
* @param rowModelPart
* @param extraPart
*/
function register(enabled, key, r, onChangeKey, rowModelPart, extraPart) {
if (!enabled) {
return;
@@ -241,6 +330,13 @@ export function useVrcxVueTable(options) {
{ enableColumnResizing: true, columnResizeMode }
);
register(
enableColumnReorder,
'columnOrder',
columnOrder,
'onColumnOrderChange'
);
if (enableFiltering) {
Object.assign(rowModels, {
getFilteredRowModel: getFilteredRowModel()
@@ -324,12 +420,26 @@ export function useVrcxVueTable(options) {
);
}
if (storageKey && persistColumnOrder) {
watch(
columnOrder,
(val) => {
const cols = table.getAllLeafColumns?.() ?? [];
persistWrite({
columnOrder: filterOrderByColumns(val, cols)
});
},
{ deep: true }
);
}
return {
table,
sorting,
pagination,
expanded,
columnPinning,
columnSizing
columnSizing,
columnOrder
};
}