Skip to content

Commit 8ea6cdb

Browse files
authored
feat: draggable column (#6047)
feat: drag explorer header column edge to resize column width Hover over the header column edge and drag to resize column - add composable to do size calculations - use request animation frame to deduplicate drag events and avoid direct repaints ( performance ) - pass the container to allow calculating drag indicator line for table with dynamic content and horizontal scroll - move column sort down to label to avoid confusion with drag - block select during drag
1 parent 3649d1c commit 8ea6cdb

File tree

4 files changed

+246
-8
lines changed

4 files changed

+246
-8
lines changed

apps/tailwind-components/app/components/table/TableEMX2.vue

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,15 @@
2626
</div>
2727

2828
<div
29+
ref="tableContainer"
2930
class="relative overflow-auto overflow-y-hidden rounded-b-theme border border-theme border-color-theme"
3031
>
32+
<div
33+
v-if="guideX !== null"
34+
class="absolute top-0 bottom-0 w-[2px] bg-button-primary pointer-events-none z-50"
35+
:style="{ left: guideX + 'px' }"
36+
/>
37+
3138
<div class="overflow-x-auto overscroll-x-contain bg-table rounded-t-3px">
3239
<table ref="table" class="text-left w-full table-fixed">
3340
<thead>
@@ -43,11 +50,20 @@
4350
</TableHeadCell>
4451
<TableHeadCell
4552
v-for="column in sortedVisibleColumns"
46-
:class="{
47-
'w-60 lg:w-full': columns.length <= 5,
48-
'w-60': columns.length > 5,
53+
:style="{
54+
width: columnWidths[column.id] + 'px',
55+
userSelect: isResizing ? 'none' : 'auto',
4956
}"
57+
class="relative group"
5058
>
59+
<div
60+
class="absolute right-0 top-0 h-full w-4 cursor-col-resize group"
61+
@mousedown.stop="startResize($event, column.id)"
62+
>
63+
<div
64+
class="absolute right-0 top-0 h-full w-[2px] bg-transparent hover:bg-button-primary"
65+
/>
66+
</div>
5167
<TableHeaderAction
5268
:column="column"
5369
:schemaId="schemaId"
@@ -78,6 +94,7 @@
7894

7995
<TableCellEMX2
8096
v-for="(column, colIndex) in sortedVisibleColumns"
97+
:style="{ width: columnWidths[column.id] + 'px' }"
8198
class="text-table-row group-hover:bg-hover"
8299
:class="{
83100
'w-60 lg:w-full': columns.length <= 5,
@@ -229,6 +246,7 @@ import TableControlColumns from "./control/Columns.vue";
229246
import TextNoResultsMessage from "../text/NoResultsMessage.vue";
230247
import TableHeaderAction from "./TableHeaderAction.vue";
231248
import DraftLabel from "../label/DraftLabel.vue";
249+
import { useColumnResize } from "../../composables/useColumnResize";
232250
233251
const props = withDefaults(
234252
defineProps<{
@@ -252,6 +270,11 @@ const refTableColumn = ref<IRefColumn>();
252270
const refSourceTableId = ref<string>(props.tableId);
253271
const columns = ref<IColumn[]>([]);
254272
273+
const tableContainer = ref<HTMLElement | null>(null);
274+
275+
const { columnWidths, guideX, startResize, setInitialWidths, isResizing } =
276+
useColumnResize(tableContainer);
277+
255278
const settings = defineModel<ITableSettings>("settings", {
256279
required: false,
257280
default: () => ({
@@ -286,6 +309,23 @@ const { data, refresh } = useAsyncData(
286309
}
287310
);
288311
312+
let widthsInitialized = false;
313+
314+
watch(
315+
() => columns.value,
316+
(newColumns) => {
317+
if (
318+
!widthsInitialized &&
319+
Array.isArray(newColumns) &&
320+
newColumns.length > 0
321+
) {
322+
setInitialWidths(newColumns);
323+
widthsInitialized = true;
324+
}
325+
},
326+
{ immediate: true, deep: true }
327+
);
328+
289329
const rows = computed(() =>
290330
Array.isArray(data.value?.tableData?.rows) ? data.value?.tableData?.rows : []
291331
);

apps/tailwind-components/app/components/table/TableHeaderAction.vue

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,7 @@ const emit = defineEmits<{
2727
}>();
2828
</script>
2929
<template>
30-
<div
31-
class="flex justify-start items-center gap-1 hover:cursor-pointer"
32-
@click="emit('sort-requested', column.id)"
33-
>
30+
<div class="flex justify-start items-center gap-1">
3431
<button
3532
:id="`table-emx2-${schemaId}-${tableId}-${column.label}-sort-btn`"
3633
type="button"
@@ -42,7 +39,11 @@ const emit = defineEmits<{
4239
: 'none'
4340
"
4441
>
45-
<span>{{ column.label }}</span>
42+
<span
43+
@click="emit('sort-requested', column.id)"
44+
class="hover:cursor-pointer"
45+
>{{ column.label }}</span
46+
>
4647
</button>
4748
<ArrowUp
4849
v-if="
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { ref, onBeforeUnmount, type Ref } from "vue";
2+
3+
export function useColumnResize(container: Ref<HTMLElement | null>) {
4+
const columnWidths = ref<Record<string, number>>({});
5+
const guideX = ref<number | null>(null);
6+
const isResizing = ref(false);
7+
8+
const resizingColumn = ref<string | null>(null);
9+
10+
const startX = ref(0);
11+
const startWidth = ref(0);
12+
13+
let rafId: number | null = null;
14+
let pendingX = 0;
15+
16+
function setInitialWidths(columns: { id: string }[], defaultWidth = 240) {
17+
columns.forEach((col) => {
18+
if (!columnWidths.value[col.id]) {
19+
columnWidths.value[col.id] = defaultWidth;
20+
}
21+
});
22+
}
23+
24+
function getRelativeX(clientX: number) {
25+
const rect = container.value?.getBoundingClientRect();
26+
return rect ? clientX - rect.left : clientX;
27+
}
28+
29+
function startResize(event: MouseEvent, columnId: string) {
30+
isResizing.value = true;
31+
resizingColumn.value = columnId;
32+
33+
startX.value = event.clientX;
34+
startWidth.value = columnWidths.value[columnId] ?? 240;
35+
36+
guideX.value = getRelativeX(event.clientX);
37+
38+
document.body.style.cursor = "col-resize";
39+
40+
window.addEventListener("mousemove", handleMouseMove);
41+
window.addEventListener("mouseup", stopResize);
42+
}
43+
44+
function handleMouseMove(event: MouseEvent) {
45+
pendingX = event.clientX;
46+
47+
if (rafId !== null) return;
48+
49+
rafId = requestAnimationFrame(updateGuide);
50+
}
51+
52+
function updateGuide() {
53+
guideX.value = getRelativeX(pendingX);
54+
rafId = null;
55+
}
56+
57+
function stopResize() {
58+
isResizing.value = false;
59+
if (!resizingColumn.value) return;
60+
61+
const diff = pendingX - startX.value;
62+
const newWidth = startWidth.value + diff;
63+
64+
const MIN_WIDTH = 80;
65+
66+
if (newWidth > MIN_WIDTH) {
67+
columnWidths.value[resizingColumn.value] = newWidth;
68+
}
69+
70+
resizingColumn.value = null;
71+
guideX.value = null;
72+
73+
document.body.style.cursor = "";
74+
75+
window.removeEventListener("mousemove", handleMouseMove);
76+
window.removeEventListener("mouseup", stopResize);
77+
78+
if (rafId !== null) {
79+
cancelAnimationFrame(rafId);
80+
rafId = null;
81+
}
82+
}
83+
84+
onBeforeUnmount(() => stopResize());
85+
86+
return {
87+
columnWidths,
88+
guideX,
89+
startResize,
90+
setInitialWidths,
91+
isResizing,
92+
};
93+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import { ref } from "vue";
3+
import { useColumnResize } from "../../../app/composables/useColumnResize";
4+
5+
describe("useColumnResize", () => {
6+
let container: any;
7+
8+
beforeEach(() => {
9+
container = ref({
10+
getBoundingClientRect: () => ({ left: 100 }),
11+
scrollLeft: 0,
12+
});
13+
14+
// mock RAF
15+
vi.stubGlobal("requestAnimationFrame", (cb: any) => {
16+
cb();
17+
return 1;
18+
});
19+
20+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
21+
});
22+
23+
it("initializes column widths", () => {
24+
const { columnWidths, setInitialWidths } = useColumnResize(container);
25+
26+
setInitialWidths([{ id: "name" }, { id: "age" }]);
27+
28+
expect(columnWidths.value.name).toBe(240);
29+
expect(columnWidths.value.age).toBe(240);
30+
});
31+
32+
it("does not override existing width", () => {
33+
const { columnWidths, setInitialWidths } = useColumnResize(container);
34+
35+
columnWidths.value.name = 500;
36+
37+
setInitialWidths([{ id: "name" }]);
38+
39+
expect(columnWidths.value.name).toBe(500);
40+
});
41+
42+
it("starts resizing correctly", () => {
43+
const { columnWidths, startResize, guideX } = useColumnResize(container);
44+
45+
columnWidths.value.name = 200;
46+
47+
startResize({ clientX: 300 } as MouseEvent, "name");
48+
49+
// 300 - container.left(100)
50+
expect(guideX.value).toBe(200);
51+
});
52+
53+
it("updates guide position on mouse move", () => {
54+
const { columnWidths, startResize, guideX } = useColumnResize(container);
55+
56+
columnWidths.value.name = 200;
57+
58+
startResize({ clientX: 300 } as MouseEvent, "name");
59+
60+
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 350 }));
61+
62+
expect(guideX.value).toBe(250);
63+
});
64+
65+
it("commits new column width on mouseup", () => {
66+
const { columnWidths, startResize } = useColumnResize(container);
67+
68+
columnWidths.value.name = 200;
69+
70+
startResize({ clientX: 300 } as MouseEvent, "name");
71+
72+
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 350 }));
73+
74+
window.dispatchEvent(new MouseEvent("mouseup"));
75+
76+
expect(columnWidths.value.name).toBe(250);
77+
});
78+
79+
it("respects minimum width", () => {
80+
const { columnWidths, startResize } = useColumnResize(container);
81+
82+
columnWidths.value.name = 200;
83+
84+
startResize({ clientX: 300 } as MouseEvent, "name");
85+
86+
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 0 }));
87+
88+
window.dispatchEvent(new MouseEvent("mouseup"));
89+
90+
expect(columnWidths.value.name).toBeGreaterThanOrEqual(80);
91+
});
92+
93+
it("removes guide after resize ends", () => {
94+
const { columnWidths, startResize, guideX } = useColumnResize(container);
95+
96+
columnWidths.value.name = 200;
97+
98+
startResize({ clientX: 300 } as MouseEvent, "name");
99+
100+
window.dispatchEvent(new MouseEvent("mouseup"));
101+
102+
expect(guideX.value).toBe(null);
103+
});
104+
});

0 commit comments

Comments
 (0)