mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-06 06:46:04 +02:00
feat: draggable datatable tablehead
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user