Skip to content

Commit 3dd7fd8

Browse files
joewinkeclaude
andcommitted
refactor: extract columnResize action, use in open-tasks and data tables
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cb63a0d commit 3dd7fd8

File tree

3 files changed

+156
-119
lines changed

3 files changed

+156
-119
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
export interface ColumnResizeOptions {
2+
/** When true, the resize handle is not added and nothing happens. Default: false */
3+
disabled?: boolean;
4+
/** Minimum column width in pixels. Default: 40 */
5+
minWidth?: number;
6+
/** Called on mousedown — use this to show the guide line (e.g. set resizeGuideX state) */
7+
onResizeStart?: (initialX: number) => void;
8+
/** Called on every mousemove with the new width */
9+
onResize: (width: number) => void;
10+
/** Called on mouseup with the final width — use this to save settings and hide the guide */
11+
onResizeEnd?: (width: number) => void;
12+
/**
13+
* Return the container element that holds the `.resize-guide` element.
14+
* If provided, the guide line position is updated on each mousemove via direct DOM manipulation,
15+
* avoiding reactive overhead.
16+
*/
17+
getGuideContainer?: () => HTMLElement | null;
18+
}
19+
20+
/**
21+
* Svelte action that attaches a column-resize handle to a `<th>` (or any element).
22+
*
23+
* Usage:
24+
* ```svelte
25+
* <th use:columnResize={{
26+
* minWidth: col.minWidth,
27+
* onResize: (w) => { columnWidths[col.id] = w; },
28+
* onResizeEnd: (w) => { saveColumnSettings(); },
29+
* getGuideContainer: () => tableContainerEl,
30+
* }}>
31+
* ```
32+
*
33+
* The action appends a 4px-wide resize handle div at the right edge of the host element.
34+
* Dragging it fires `onResize` continuously and `onResizeEnd` on mouse-up.
35+
*/
36+
export function columnResize(node: HTMLElement, options: ColumnResizeOptions): { update(opts: ColumnResizeOptions): void; destroy(): void } {
37+
let opts = options;
38+
39+
// If disabled, return a no-op handle
40+
if (opts.disabled) {
41+
return {
42+
update(newOptions: ColumnResizeOptions) { opts = newOptions; },
43+
destroy() {},
44+
};
45+
}
46+
47+
// Build the resize handle element
48+
const handle = document.createElement('div');
49+
handle.className = 'col-resize-handle';
50+
handle.style.cssText = [
51+
'position: absolute',
52+
'right: 0',
53+
'top: 0',
54+
'bottom: 0',
55+
'width: 5px',
56+
'cursor: col-resize',
57+
'z-index: 2',
58+
].join(';');
59+
60+
// Make the host element position-relative if it isn't already
61+
const hostPosition = getComputedStyle(node).position;
62+
if (hostPosition === 'static') {
63+
node.style.position = 'relative';
64+
}
65+
node.appendChild(handle);
66+
67+
// Resize tracking state
68+
let startX = 0;
69+
let startWidth = 0;
70+
let currentWidth = 0;
71+
72+
function updateGuide(clientX: number) {
73+
const container = opts.getGuideContainer?.();
74+
if (!container) return;
75+
const guide = container.querySelector<HTMLElement>('.resize-guide');
76+
if (!guide) return;
77+
const rect = container.getBoundingClientRect();
78+
guide.style.left = `${clientX - rect.left + (container as HTMLElement).scrollLeft}px`;
79+
}
80+
81+
function onMouseMove(e: MouseEvent) {
82+
const minWidth = opts.minWidth ?? 40;
83+
const diff = e.clientX - startX;
84+
const newWidth = Math.max(minWidth, startWidth + diff);
85+
currentWidth = newWidth;
86+
opts.onResize(newWidth);
87+
updateGuide(e.clientX);
88+
}
89+
90+
function onMouseUp() {
91+
document.body.style.cursor = '';
92+
document.body.style.userSelect = '';
93+
// Call onResizeEnd — the parent is responsible for clearing the guide line
94+
// (typically by setting resizeGuideX = null in its onResizeEnd handler)
95+
opts.onResizeEnd?.(currentWidth);
96+
document.removeEventListener('mousemove', onMouseMove);
97+
document.removeEventListener('mouseup', onMouseUp);
98+
}
99+
100+
function onMouseDown(e: MouseEvent) {
101+
e.preventDefault();
102+
e.stopPropagation();
103+
startX = e.clientX;
104+
startWidth = node.offsetWidth;
105+
currentWidth = startWidth;
106+
document.body.style.cursor = 'col-resize';
107+
document.body.style.userSelect = 'none';
108+
opts.onResizeStart?.(e.clientX);
109+
updateGuide(e.clientX);
110+
document.addEventListener('mousemove', onMouseMove);
111+
document.addEventListener('mouseup', onMouseUp);
112+
}
113+
114+
handle.addEventListener('mousedown', onMouseDown);
115+
116+
return {
117+
update(newOptions: ColumnResizeOptions) {
118+
opts = newOptions;
119+
},
120+
destroy() {
121+
handle.removeEventListener('mousedown', onMouseDown);
122+
document.removeEventListener('mousemove', onMouseMove);
123+
document.removeEventListener('mouseup', onMouseUp);
124+
if (handle.parentNode === node) {
125+
node.removeChild(handle);
126+
}
127+
},
128+
};
129+
}

ide/src/routes/data/+page.svelte

Lines changed: 14 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
* - Right: Table view with inline editing, add/delete rows, SQL console
88
*/
99
10-
import { onDestroy, tick } from 'svelte';
10+
import { tick } from 'svelte';
1111
import { browser } from '$app/environment';
1212
import { page } from '$app/stores';
1313
import { goto } from '$app/navigation';
1414
import { reveal } from '$lib/actions/reveal';
1515
import { saveColumnSettings as saveColumnSettingsUtil, loadColumnSettings as loadColumnSettingsUtil } from '$lib/utils/columnStorage';
1616
import { toggleSort as toggleSortUtil } from '$lib/utils/tableSort';
17+
import { columnResize } from '$lib/actions/columnResize';
1718
import { successToast, errorToast } from '$lib/stores/toasts.svelte';
1819
import type { SemanticType, ColumnConfig, ColumnSchema } from '$lib/types/dataTable';
1920
import { SEMANTIC_TYPE_INFO, SEMANTIC_TO_SQLITE } from '$lib/types/dataTable';
@@ -546,10 +547,7 @@
546547
let colDraggedIndex = $state<number | null>(null);
547548
let colDragOverIndex = $state<number | null>(null);
548549
549-
// Column resize state
550-
let colResizing = $state<string | null>(null);
551-
let colResizeStartX = 0;
552-
let colResizeStartWidth = 0;
550+
// Column resize state (guide line only — resize logic is in the columnResize action)
553551
let resizeGuideX = $state<number | null>(null);
554552
let dataTableContainerEl: HTMLDivElement | undefined = $state();
555553
@@ -2929,52 +2927,7 @@
29292927
}
29302928
});
29312929
2932-
// Column resize handlers
2933-
function handleResizeStart(e: MouseEvent, colName: string) {
2934-
e.preventDefault();
2935-
e.stopPropagation();
2936-
colResizing = colName;
2937-
colResizeStartX = e.clientX;
2938-
const th = (e.target as HTMLElement).parentElement;
2939-
colResizeStartWidth = th ? th.offsetWidth : 120;
2940-
updateResizeGuide(e.clientX);
2941-
document.body.style.cursor = 'col-resize';
2942-
document.body.style.userSelect = 'none';
2943-
document.addEventListener('mousemove', handleResizeMove);
2944-
document.addEventListener('mouseup', handleResizeEnd);
2945-
}
2946-
2947-
function handleResizeMove(e: MouseEvent) {
2948-
if (!colResizing) return;
2949-
const diff = e.clientX - colResizeStartX;
2950-
const newWidth = Math.max(50, colResizeStartWidth + diff);
2951-
columnWidths = { ...columnWidths, [colResizing]: newWidth };
2952-
updateResizeGuide(e.clientX);
2953-
}
2954-
2955-
function updateResizeGuide(clientX: number) {
2956-
if (!dataTableContainerEl) return;
2957-
const rect = dataTableContainerEl.getBoundingClientRect();
2958-
resizeGuideX = clientX - rect.left + dataTableContainerEl.scrollLeft;
2959-
}
2960-
2961-
function handleResizeEnd() {
2962-
document.body.style.cursor = '';
2963-
document.body.style.userSelect = '';
2964-
if (colResizing) {
2965-
persistColumnWidths();
2966-
colResizing = null;
2967-
}
2968-
resizeGuideX = null;
2969-
document.removeEventListener('mousemove', handleResizeMove);
2970-
document.removeEventListener('mouseup', handleResizeEnd);
2971-
}
2972-
2973-
onDestroy(() => {
2974-
if (!browser) return;
2975-
document.removeEventListener('mousemove', handleResizeMove);
2976-
document.removeEventListener('mouseup', handleResizeEnd);
2977-
});
2930+
// Column resize is now handled by the columnResize action on each <th>.
29782931
29792932
async function handleCtxDuplicate() {
29802933
if (!ctxCol) return;
@@ -3882,15 +3835,21 @@
38823835
class:col-dragging={colDraggedIndex === colIdx}
38833836
class:col-drag-over-left={colDragOverIndex === colIdx && colDraggedIndex !== null && colDraggedIndex > colIdx}
38843837
class:col-drag-over-right={colDragOverIndex === colIdx && colDraggedIndex !== null && colDraggedIndex < colIdx}
3885-
class:col-resizing={colResizing === col.name}
38863838
style={colWidth ? `width: ${colWidth}px; min-width: ${colWidth}px; max-width: ${colWidth}px;` : ''}
3887-
draggable={colResizing ? 'false' : 'true'}
3839+
draggable="true"
38883840
ondragstart={(e) => handleColDragStart(e, colIdx)}
38893841
ondragend={handleColDragEnd}
38903842
ondragover={(e) => handleColDragOver(e, colIdx)}
38913843
ondragleave={handleColDragLeave}
38923844
ondrop={(e) => handleColDrop(e, colIdx)}
38933845
oncontextmenu={(e) => handleColContextMenu(col, colIdx, e)}
3846+
use:columnResize={{
3847+
minWidth: 50,
3848+
onResizeStart: () => { resizeGuideX = 0; },
3849+
onResize: (w) => { columnWidths = { ...columnWidths, [col.name]: w }; },
3850+
onResizeEnd: () => { resizeGuideX = null; persistColumnWidths(); },
3851+
getGuideContainer: () => dataTableContainerEl ?? null,
3852+
}}
38943853
>
38953854
<button class="sort-btn" onclick={() => toggleSort(col.name)}>
38963855
{col.name}
@@ -3928,11 +3887,6 @@
39283887
onClose={() => { columnSettingsOpen = null; autoFocusExpression = false; }}
39293888
/>
39303889
{/if}
3931-
<!-- svelte-ignore a11y_no_static_element_interactions -->
3932-
<div
3933-
class="col-resize-handle"
3934-
onmousedown={(e) => handleResizeStart(e, col.name)}
3935-
></div>
39363890
</th>
39373891
{/each}
39383892
{#if !isSystemTableSelected}<th class="actions-col"></th>{/if}
@@ -6093,7 +6047,7 @@
60936047
.col-drag-over-right {
60946048
box-shadow: inset -3px 0 0 0 oklch(0.65 0.15 200);
60956049
}
6096-
.col-resize-handle {
6050+
:global(.col-resize-handle) {
60976051
position: absolute;
60986052
right: 0;
60996053
top: 0;
@@ -6102,8 +6056,7 @@
61026056
cursor: col-resize;
61036057
z-index: 2;
61046058
}
6105-
.col-resize-handle:hover,
6106-
.col-resizing .col-resize-handle {
6059+
:global(.col-resize-handle:hover) {
61076060
background: oklch(0.55 0.15 200);
61086061
}
61096062

ide/src/routes/open-tasks/+page.svelte

Lines changed: 13 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { saveColumnSettings as saveColumnSettingsUtil, loadColumnSettings as loadColumnSettingsUtil } from '$lib/utils/columnStorage';
1313
import { toggleSort as toggleSortUtil } from '$lib/utils/tableSort';
1414
import ManageColumnsDropdown from '$lib/components/ManageColumnsDropdown.svelte';
15+
import { columnResize } from '$lib/actions/columnResize';
1516
1617
interface Task {
1718
id: string;
@@ -122,10 +123,7 @@
122123
let columnWidths = $state<Record<string, number>>({});
123124
let hiddenColumns = $state<Set<string>>(new Set());
124125
125-
// Column resize state
126-
let colResizing = $state<string | null>(null);
127-
let colResizeStartX = $state(0);
128-
let colResizeStartW = $state(0);
126+
// Column resize state (guide line only — actual resize logic is in the columnResize action)
129127
let resizeGuideX = $state<number | null>(null);
130128
let tableContainerEl: HTMLElement | undefined = $state();
131129
@@ -170,48 +168,6 @@
170168
return `width: ${w}px; min-width: ${col.minWidth}px;`;
171169
}
172170
173-
// --- Column resize ---
174-
function updateResizeGuide(clientX: number) {
175-
if (!tableContainerEl) return;
176-
const rect = tableContainerEl.getBoundingClientRect();
177-
resizeGuideX = clientX - rect.left + tableContainerEl.scrollLeft;
178-
}
179-
180-
function handleResizeStart(colId: string, e: MouseEvent) {
181-
e.preventDefault();
182-
e.stopPropagation();
183-
colResizing = colId;
184-
colResizeStartX = e.clientX;
185-
const col = ALL_COLUMNS.find(c => c.id === colId)!;
186-
colResizeStartW = getColWidth(col);
187-
updateResizeGuide(e.clientX);
188-
document.body.style.cursor = 'col-resize';
189-
document.body.style.userSelect = 'none';
190-
window.addEventListener('mousemove', handleResizeMove);
191-
window.addEventListener('mouseup', handleResizeEnd);
192-
}
193-
194-
function handleResizeMove(e: MouseEvent) {
195-
if (!colResizing) return;
196-
const col = ALL_COLUMNS.find(c => c.id === colResizing)!;
197-
const diff = e.clientX - colResizeStartX;
198-
const newW = Math.max(col.minWidth, colResizeStartW + diff);
199-
columnWidths = { ...columnWidths, [colResizing]: newW };
200-
updateResizeGuide(e.clientX);
201-
}
202-
203-
function handleResizeEnd() {
204-
document.body.style.cursor = '';
205-
document.body.style.userSelect = '';
206-
if (colResizing) {
207-
colResizing = null;
208-
saveColumnSettings();
209-
}
210-
resizeGuideX = null;
211-
window.removeEventListener('mousemove', handleResizeMove);
212-
window.removeEventListener('mouseup', handleResizeEnd);
213-
}
214-
215171
// --- Column drag reorder (header) ---
216172
function handleColDragStart(index: number, e: DragEvent) {
217173
colDraggedIndex = index;
@@ -881,7 +837,6 @@
881837
{#each visibleColumns as col, i}
882838
<th
883839
class="th-cell"
884-
class:col-resizing={colResizing === col.id}
885840
class:th-dragging={colDraggedIndex === i}
886841
class:th-drag-over={colDragOverIndex === i && colDraggedIndex !== null && colDraggedIndex !== i}
887842
draggable="true"
@@ -891,20 +846,21 @@
891846
ondrop={(e) => handleColDrop(i, e)}
892847
onclick={() => { if (col.sortable && col.sortField) toggleSort(col.sortField); }}
893848
style={col.id === 'type' ? 'text-align: center;' : ''}
849+
use:columnResize={{
850+
disabled: col.id === 'actions',
851+
minWidth: col.minWidth,
852+
onResizeStart: () => { resizeGuideX = 0; },
853+
onResize: (w) => { columnWidths = { ...columnWidths, [col.id]: w }; },
854+
onResizeEnd: () => { resizeGuideX = null; saveColumnSettings(); },
855+
getGuideContainer: () => tableContainerEl ?? null,
856+
}}
894857
>
895858
<span class="th-label">
896859
{col.label}
897860
{#if col.sortable && col.sortField === sortField}
898861
{sortDir === 'asc' ? '' : ''}
899862
{/if}
900863
</span>
901-
{#if col.id !== 'actions'}
902-
<!-- svelte-ignore a11y_no_static_element_interactions -->
903-
<div
904-
class="col-resize-handle"
905-
onmousedown={(e) => handleResizeStart(col.id, e)}
906-
></div>
907-
{/if}
908864
</th>
909865
{/each}
910866
</tr>
@@ -1534,8 +1490,8 @@
15341490
pointer-events: none;
15351491
}
15361492
1537-
/* Column resize handle */
1538-
.col-resize-handle {
1493+
/* Column resize handle (action-injected, must use :global since div is created dynamically) */
1494+
:global(.col-resize-handle) {
15391495
position: absolute;
15401496
right: 0;
15411497
top: 0;
@@ -1544,8 +1500,7 @@
15441500
cursor: col-resize;
15451501
z-index: 2;
15461502
}
1547-
.col-resize-handle:hover,
1548-
.col-resizing .col-resize-handle {
1503+
:global(.col-resize-handle:hover) {
15491504
background: oklch(0.55 0.15 200);
15501505
}
15511506

0 commit comments

Comments
 (0)