Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions apps/tailwind-components/app/components/table/TableEMX2.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@
</div>

<div
ref="tableContainer"
class="relative overflow-auto overflow-y-hidden rounded-b-theme border border-theme border-color-theme"
>
<div
v-if="guideX !== null"
class="absolute top-0 bottom-0 w-[2px] bg-button-primary pointer-events-none z-50"
:style="{ left: guideX + 'px' }"
/>

<div class="overflow-x-auto overscroll-x-contain bg-table rounded-t-3px">
<table ref="table" class="text-left w-full table-fixed">
<thead>
Expand All @@ -43,11 +50,20 @@
</TableHeadCell>
<TableHeadCell
v-for="column in sortedVisibleColumns"
:class="{
'w-60 lg:w-full': columns.length <= 5,
'w-60': columns.length > 5,
:style="{
width: columnWidths[column.id] + 'px',
userSelect: isResizing ? 'none' : 'auto',
}"
class="relative group"
>
<div
class="absolute right-0 top-0 h-full w-4 cursor-col-resize group"
@mousedown.stop="startResize($event, column.id)"
>
<div
class="absolute right-0 top-0 h-full w-[2px] bg-transparent hover:bg-button-primary"
/>
</div>
<TableHeaderAction
:column="column"
:schemaId="schemaId"
Expand Down Expand Up @@ -78,6 +94,7 @@

<TableCellEMX2
v-for="(column, colIndex) in sortedVisibleColumns"
:style="{ width: columnWidths[column.id] + 'px' }"
class="text-table-row group-hover:bg-hover"
:class="{
'w-60 lg:w-full': columns.length <= 5,
Expand Down Expand Up @@ -229,6 +246,7 @@ import TableControlColumns from "./control/Columns.vue";
import TextNoResultsMessage from "../text/NoResultsMessage.vue";
import TableHeaderAction from "./TableHeaderAction.vue";
import DraftLabel from "../label/DraftLabel.vue";
import { useColumnResize } from "../../composables/useColumnResize";

const props = withDefaults(
defineProps<{
Expand All @@ -252,6 +270,11 @@ const refTableColumn = ref<IRefColumn>();
const refSourceTableId = ref<string>(props.tableId);
const columns = ref<IColumn[]>([]);

const tableContainer = ref<HTMLElement | null>(null);

const { columnWidths, guideX, startResize, setInitialWidths, isResizing } =
useColumnResize(tableContainer);

const settings = defineModel<ITableSettings>("settings", {
required: false,
default: () => ({
Expand Down Expand Up @@ -286,6 +309,23 @@ const { data, refresh } = useAsyncData(
}
);

let widthsInitialized = false;

watch(
() => columns.value,
(newColumns) => {
if (
!widthsInitialized &&
Array.isArray(newColumns) &&
newColumns.length > 0
) {
setInitialWidths(newColumns);
widthsInitialized = true;
}
},
{ immediate: true, deep: true }
);

const rows = computed(() =>
Array.isArray(data.value?.tableData?.rows) ? data.value?.tableData?.rows : []
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,7 @@ const emit = defineEmits<{
}>();
</script>
<template>
<div
class="flex justify-start items-center gap-1 hover:cursor-pointer"
@click="emit('sort-requested', column.id)"
>
<div class="flex justify-start items-center gap-1">
<button
:id="`table-emx2-${schemaId}-${tableId}-${column.label}-sort-btn`"
type="button"
Expand All @@ -42,7 +39,11 @@ const emit = defineEmits<{
: 'none'
"
>
<span>{{ column.label }}</span>
<span
@click="emit('sort-requested', column.id)"
class="hover:cursor-pointer"
>{{ column.label }}</span
>
Comment on lines +42 to +46
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sort click handler was moved from the wrapping div to a span inside the button, but the button itself still has @click.prevent which only prevents default — it doesn't trigger sorting. This means clicking the button area outside the span text (e.g., padding) no longer triggers sorting, reducing the clickable area. The @click for sort should be on the button element instead, replacing the @click.prevent.

Copilot uses AI. Check for mistakes.
</button>
<ArrowUp
v-if="
Expand Down
93 changes: 93 additions & 0 deletions apps/tailwind-components/app/composables/useColumnResize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { ref, onBeforeUnmount, type Ref } from "vue";

export function useColumnResize(container: Ref<HTMLElement | null>) {
const columnWidths = ref<Record<string, number>>({});
const guideX = ref<number | null>(null);
const isResizing = ref(false);

const resizingColumn = ref<string | null>(null);

const startX = ref(0);
const startWidth = ref(0);

let rafId: number | null = null;
let pendingX = 0;

function setInitialWidths(columns: { id: string }[], defaultWidth = 240) {
columns.forEach((col) => {
if (!columnWidths.value[col.id]) {
columnWidths.value[col.id] = defaultWidth;
}
});
}

function getRelativeX(clientX: number) {
const rect = container.value?.getBoundingClientRect();
return rect ? clientX - rect.left : clientX;
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guideX position is calculated relative to the container's left edge but doesn't account for scrollLeft. If the table is scrolled horizontally, the guide line will appear at the wrong position. getRelativeX should add container.value.scrollLeft to the result.

Suggested change
return rect ? clientX - rect.left : clientX;
const scrollLeft = container.value?.scrollLeft ?? 0;
return rect ? clientX - rect.left + scrollLeft : clientX;

Copilot uses AI. Check for mistakes.
}

function startResize(event: MouseEvent, columnId: string) {
isResizing.value = true;
resizingColumn.value = columnId;

startX.value = event.clientX;
startWidth.value = columnWidths.value[columnId] ?? 240;

guideX.value = getRelativeX(event.clientX);

document.body.style.cursor = "col-resize";

window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", stopResize);
}
Comment on lines +29 to +42
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pendingX is initialized to 0 and is only updated on mousemove. If the user does a mousedown then mouseup without moving the mouse, pendingX is 0, causing diff = 0 - startX which produces a large negative value. While the MIN_WIDTH guard prevents the width from going negative, the column width silently stays unchanged with no feedback, and pendingX retains stale state across resize operations. pendingX should be initialized to event.clientX in startResize to ensure correct behavior when no mousemove occurs.

Copilot uses AI. Check for mistakes.

function handleMouseMove(event: MouseEvent) {
pendingX = event.clientX;

if (rafId !== null) return;

rafId = requestAnimationFrame(updateGuide);
}

function updateGuide() {
guideX.value = getRelativeX(pendingX);
rafId = null;
}

function stopResize() {
isResizing.value = false;
if (!resizingColumn.value) return;

const diff = pendingX - startX.value;
const newWidth = startWidth.value + diff;

const MIN_WIDTH = 80;

if (newWidth > MIN_WIDTH) {
columnWidths.value[resizingColumn.value] = newWidth;
}
Comment on lines +66 to +68
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When newWidth <= MIN_WIDTH, the column width is left unchanged at its previous value rather than being clamped to MIN_WIDTH. This means dragging far to the left has no effect instead of shrinking the column to its minimum. Consider clamping: columnWidths.value[resizingColumn.value] = Math.max(newWidth, MIN_WIDTH).

Suggested change
if (newWidth > MIN_WIDTH) {
columnWidths.value[resizingColumn.value] = newWidth;
}
columnWidths.value[resizingColumn.value] = Math.max(newWidth, MIN_WIDTH);

Copilot uses AI. Check for mistakes.

resizingColumn.value = null;
guideX.value = null;

document.body.style.cursor = "";

window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", stopResize);

if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}

onBeforeUnmount(() => stopResize());

return {
columnWidths,
guideX,
startResize,
setInitialWidths,
isResizing,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { ref } from "vue";
import { useColumnResize } from "../../../app/composables/useColumnResize";

describe("useColumnResize", () => {
let container: any;

beforeEach(() => {
container = ref({
getBoundingClientRect: () => ({ left: 100 }),
scrollLeft: 0,
});

// mock RAF
vi.stubGlobal("requestAnimationFrame", (cb: any) => {
cb();
return 1;
});

vi.stubGlobal("cancelAnimationFrame", vi.fn());
});

it("initializes column widths", () => {
const { columnWidths, setInitialWidths } = useColumnResize(container);

setInitialWidths([{ id: "name" }, { id: "age" }]);

expect(columnWidths.value.name).toBe(240);
expect(columnWidths.value.age).toBe(240);
});

it("does not override existing width", () => {
const { columnWidths, setInitialWidths } = useColumnResize(container);

columnWidths.value.name = 500;

setInitialWidths([{ id: "name" }]);

expect(columnWidths.value.name).toBe(500);
});

it("starts resizing correctly", () => {
const { columnWidths, startResize, guideX } = useColumnResize(container);

columnWidths.value.name = 200;

startResize({ clientX: 300 } as MouseEvent, "name");

// 300 - container.left(100)
expect(guideX.value).toBe(200);
});

it("updates guide position on mouse move", () => {
const { columnWidths, startResize, guideX } = useColumnResize(container);

columnWidths.value.name = 200;

startResize({ clientX: 300 } as MouseEvent, "name");

window.dispatchEvent(new MouseEvent("mousemove", { clientX: 350 }));

expect(guideX.value).toBe(250);
});

it("commits new column width on mouseup", () => {
const { columnWidths, startResize } = useColumnResize(container);

columnWidths.value.name = 200;

startResize({ clientX: 300 } as MouseEvent, "name");

window.dispatchEvent(new MouseEvent("mousemove", { clientX: 350 }));

window.dispatchEvent(new MouseEvent("mouseup"));

expect(columnWidths.value.name).toBe(250);
});

it("respects minimum width", () => {
const { columnWidths, startResize } = useColumnResize(container);

columnWidths.value.name = 200;

startResize({ clientX: 300 } as MouseEvent, "name");

window.dispatchEvent(new MouseEvent("mousemove", { clientX: 0 }));

window.dispatchEvent(new MouseEvent("mouseup"));

expect(columnWidths.value.name).toBeGreaterThanOrEqual(80);
});

it("removes guide after resize ends", () => {
const { columnWidths, startResize, guideX } = useColumnResize(container);

columnWidths.value.name = 200;

startResize({ clientX: 300 } as MouseEvent, "name");

window.dispatchEvent(new MouseEvent("mouseup"));

expect(guideX.value).toBe(null);
});
});
Loading