-
Notifications
You must be signed in to change notification settings - Fork 22
feat: draggable column #6047
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: draggable column #6047
Changes from all commits
2797fcd
019bbb6
512d0f2
2082aed
ce2a8db
45f1d4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||||||||||
|
||||||||||
| return rect ? clientX - rect.left : clientX; | |
| const scrollLeft = container.value?.scrollLeft ?? 0; | |
| return rect ? clientX - rect.left + scrollLeft : clientX; |
Copilot
AI
Mar 12, 2026
There was a problem hiding this comment.
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
AI
Mar 12, 2026
There was a problem hiding this comment.
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).
| if (newWidth > MIN_WIDTH) { | |
| columnWidths.value[resizingColumn.value] = newWidth; | |
| } | |
| columnWidths.value[resizingColumn.value] = Math.max(newWidth, MIN_WIDTH); |
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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
divto aspaninside thebutton, but thebuttonitself still has@click.preventwhich 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@clickfor sort should be on thebuttonelement instead, replacing the@click.prevent.