Skip to content

Commit e011e37

Browse files
fix: allow expand during the same drag event as collapse (#40)
1 parent c0c2ea1 commit e011e37

File tree

3 files changed

+209
-43
lines changed

3 files changed

+209
-43
lines changed

packages/vue-split-panel/playground/src/App.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
<script lang="ts" setup>
2+
import { ref } from 'vue';
23
import { SplitPanel } from '../../src';
4+
5+
const size = ref(250);
6+
const collapsed = ref(false);
37
</script>
48

59
<template>
6-
<SplitPanel id="panels-root" :snap-points="[25, 50]">
10+
<SplitPanel id="panels-root" v-model:size="size" v-model:collapsed="collapsed" collapsible :collapse-threshold="100" :min-size="250" :max-size="400" size-unit="px">
711
<template #start>
812
<div id="a" class="panel">
913
Panel A
Lines changed: 193 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import type { UseDraggableReturn } from '@vueuse/core';
12
import type { Ref } from 'vue';
23
import type { UsePointerOptions } from './use-pointer';
3-
import { beforeEach, describe, expect, it } from 'vitest';
4-
import { computed, ref } from 'vue';
4+
import { useDraggable } from '@vueuse/core';
5+
import { beforeEach, describe, expect, it, vi } from 'vitest';
6+
import { computed, nextTick, ref } from 'vue';
57
import { usePointer } from './use-pointer';
68

9+
vi.mock('@vueuse/core', async () => {
10+
const actual = await vi.importActual('@vueuse/core');
11+
12+
return {
13+
...actual,
14+
useDraggable: vi.fn(),
15+
};
16+
});
17+
718
describe('usePointer', () => {
819
let collapsed: Ref<boolean>;
920
let sizePercentage: Ref<number>;
@@ -12,6 +23,10 @@ describe('usePointer', () => {
1223
let dividerEl: Ref<HTMLElement | null>;
1324
let panelEl: Ref<HTMLElement | null>;
1425

26+
let mockDragX: Ref<number>;
27+
let mockDragY: Ref<number>;
28+
let mockDragDragging: Ref<boolean>;
29+
1530
beforeEach(() => {
1631
collapsed = ref(false);
1732
sizePercentage = ref(50);
@@ -40,6 +55,12 @@ describe('usePointer', () => {
4055
minSizePixels: computed(() => 50),
4156
snapPixels: computed(() => [100, 200, 300]),
4257
};
58+
59+
mockDragX = ref(75);
60+
mockDragY = ref(75);
61+
mockDragDragging = ref(false);
62+
63+
vi.mocked(useDraggable).mockReturnValue({ x: mockDragX, y: mockDragY, isDragging: mockDragDragging } as UseDraggableReturn);
4364
});
4465

4566
it('should return handleDblClick and isDragging', () => {
@@ -51,51 +72,190 @@ describe('usePointer', () => {
5172
expect(typeof result.isDragging.value).toBe('boolean');
5273
});
5374

54-
it('should handle double click to snap to closest point', () => {
55-
const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options);
75+
describe('dbl click', () => {
76+
it('should handle double click to snap to closest point', () => {
77+
const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options);
5678

57-
sizePixels.value = 195; // Close to 200
58-
handleDblClick();
79+
sizePixels.value = 195; // Close to 200
80+
handleDblClick();
5981

60-
expect(sizePixels.value).toBe(200);
61-
});
82+
expect(sizePixels.value).toBe(200);
83+
});
6284

63-
it('should remain collapsed on handle double click when collapsed', () => {
64-
collapsed.value = true;
65-
const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options);
85+
it('should remain collapsed on handle double click when collapsed', () => {
86+
collapsed.value = true;
87+
const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options);
6688

67-
handleDblClick();
89+
handleDblClick();
6890

69-
expect(collapsed.value).toBe(true);
70-
});
91+
expect(collapsed.value).toBe(true);
92+
});
7193

72-
it('should not snap on double click when disabled', () => {
73-
options.disabled = ref(true);
74-
const originalSize = sizePixels.value;
75-
const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options);
94+
it('should not snap on double click when disabled', () => {
95+
options.disabled = ref(true);
96+
const originalSize = sizePixels.value;
97+
const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options);
7698

77-
handleDblClick();
99+
handleDblClick();
78100

79-
expect(sizePixels.value).toBe(originalSize);
80-
});
101+
expect(sizePixels.value).toBe(originalSize);
102+
});
103+
104+
it('should not snap on double click when no snap points exist', () => {
105+
options.snapPixels = computed(() => []);
106+
const originalSize = sizePixels.value;
107+
const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options);
81108

82-
it('should not snap on double click when no snap points exist', () => {
83-
options.snapPixels = computed(() => []);
84-
const originalSize = sizePixels.value;
85-
const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options);
109+
handleDblClick();
86110

87-
handleDblClick();
111+
expect(sizePixels.value).toBe(originalSize);
112+
});
88113

89-
expect(sizePixels.value).toBe(originalSize);
114+
it('should handle when collapsible is false', () => {
115+
options.collapsible = ref(false);
116+
collapsed.value = false;
117+
const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options);
118+
119+
handleDblClick();
120+
121+
expect(collapsed.value).toBe(false); // Should remain visible when not collapsible
122+
});
90123
});
91124

92-
it('should handle when collapsible is false', () => {
93-
options.collapsible = ref(false);
94-
collapsed.value = false;
95-
const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options);
125+
describe('dragging', () => {
126+
it('should not do anything when disabled', async () => {
127+
options.disabled = true;
128+
129+
usePointer(collapsed, sizePercentage, sizePixels, options);
130+
131+
mockDragDragging.value = true;
132+
mockDragX.value = 30; // below the minsize - threshold
133+
await nextTick();
134+
expect(collapsed.value).toBe(false);
135+
});
136+
137+
it('should update sizePercentage when dragging horizontally', async () => {
138+
usePointer(collapsed, sizePercentage, sizePixels, options);
139+
140+
mockDragX.value = 100; // Move to 100px
141+
await nextTick();
142+
expect(sizePercentage.value).toBe(25); // 100/400 * 100 = 25%
143+
});
144+
145+
it('should update sizePercentage when dragging vertically', async () => {
146+
options.orientation = ref('vertical');
147+
usePointer(collapsed, sizePercentage, sizePixels, options);
148+
149+
mockDragY.value = 150; // Move to 150px
150+
await nextTick();
151+
expect(sizePercentage.value).toBe(37.5); // 150/400 * 100 = 37.5%
152+
});
153+
154+
it('should handle primary end positioning', async () => {
155+
options.primary = ref('end');
156+
usePointer(collapsed, sizePercentage, sizePixels, options);
157+
158+
mockDragX.value = 100; // Drag position at 100px
159+
await nextTick();
160+
// With primary end, actual position = 400 - 100 = 300px
161+
162+
expect(sizePercentage.value).toBe(75); // 300/400 * 100 = 75%
163+
});
164+
165+
it('should collapse when dragging below collapse threshold', async () => {
166+
options.minSizePixels = computed(() => 50);
167+
options.collapseThreshold = ref(10);
168+
usePointer(collapsed, sizePercentage, sizePixels, options);
169+
170+
mockDragX.value = 30; // Below minSize (50) - collapseThreshold (10) = 40
171+
await nextTick();
172+
expect(collapsed.value).toBe(true);
173+
});
174+
175+
it('should expand when dragging above expand threshold', async () => {
176+
collapsed.value = true;
177+
options.collapseThreshold = ref(15);
178+
usePointer(collapsed, sizePercentage, sizePixels, options);
179+
180+
mockDragX.value = 20; // Above collapseThreshold (15)
181+
await nextTick();
182+
expect(collapsed.value).toBe(false);
183+
});
184+
185+
it('should not collapse when collapsible is false', async () => {
186+
options.collapsible = ref(false);
187+
usePointer(collapsed, sizePercentage, sizePixels, options);
188+
189+
mockDragX.value = 10; // Very low position
190+
await nextTick();
191+
expect(collapsed.value).toBe(false);
192+
});
193+
194+
it('should snap to snap points within threshold', async () => {
195+
options.snapPixels = computed(() => [100, 200, 300]);
196+
options.snapThreshold = ref(8);
197+
usePointer(collapsed, sizePercentage, sizePixels, options);
198+
199+
mockDragX.value = 195; // Within 8px of snap point 200
200+
await nextTick();
201+
expect(sizePercentage.value).toBe(50); // 200/400 * 100 = 50%
202+
});
203+
204+
it('should not snap when outside snap threshold', async () => {
205+
options.snapPixels = computed(() => [100, 200, 300]);
206+
options.snapThreshold = ref(5);
207+
usePointer(collapsed, sizePercentage, sizePixels, options);
208+
209+
mockDragX.value = 190; // Outside 5px threshold of snap point 200
210+
await nextTick();
211+
expect(sizePercentage.value).toBe(47.5); // 190/400 * 100 = 47.5%
212+
});
213+
214+
it('should handle RTL direction with horizontal orientation', async () => {
215+
options.direction = ref('rtl');
216+
options.orientation = ref('horizontal');
217+
options.snapPixels = computed(() => [100]);
218+
options.snapThreshold = ref(5);
219+
usePointer(collapsed, sizePercentage, sizePixels, options);
220+
221+
// With RTL, snap point 100 becomes 400 - 100 = 300
222+
mockDragX.value = 298; // Within threshold of transformed snap point
223+
await nextTick();
224+
expect(sizePercentage.value).toBe(75); // 300/400 * 100 = 75%
225+
});
226+
227+
it('should clamp sizePercentage between 0 and 100', async () => {
228+
usePointer(collapsed, sizePercentage, sizePixels, options);
229+
230+
mockDragX.value = -50; // Negative position
231+
await nextTick();
232+
expect(sizePercentage.value).toBe(0);
233+
234+
mockDragX.value = 500; // Beyond component size
235+
await nextTick();
236+
expect(sizePercentage.value).toBe(100);
237+
});
238+
239+
it('should update threshold location when dragging stops', async () => {
240+
usePointer(collapsed, sizePercentage, sizePixels, options);
241+
242+
// Start dragging
243+
mockDragDragging.value = true;
244+
await nextTick();
245+
246+
// Collapse during drag
247+
mockDragX.value = 30;
248+
await nextTick();
249+
expect(collapsed.value).toBe(true);
96250

97-
handleDblClick();
251+
// Stop dragging
252+
mockDragDragging.value = false;
253+
await nextTick();
98254

99-
expect(collapsed.value).toBe(false); // Should remain visible when not collapsible
255+
// Should now be in expand mode for next drag
256+
mockDragX.value = 5; // Below expand threshold
257+
await nextTick();
258+
expect(collapsed.value).toBe(true); // Should remain collapsed
259+
});
100260
});
101261
});

packages/vue-split-panel/src/composables/use-pointer.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export interface UsePointerOptions {
2424
export const usePointer = (collapsed: Ref<boolean>, sizePercentage: Ref<number>, sizePixels: Ref<number>, options: UsePointerOptions) => {
2525
const { x: dividerX, y: dividerY, isDragging } = useDraggable(options.dividerEl, { containerElement: options.panelEl });
2626

27-
let hasToggledDuringCurrentDrag = false;
27+
let thresholdLocation: 'expand' | 'collapse' = collapsed.value ? 'expand' : 'collapse';
2828

2929
watch([dividerX, dividerY], ([newX, newY]) => {
3030
if (toValue(options.disabled)) return;
@@ -35,17 +35,17 @@ export const usePointer = (collapsed: Ref<boolean>, sizePercentage: Ref<number>,
3535
newPositionInPixels = options.componentSize.value - newPositionInPixels;
3636
}
3737

38-
if (toValue(options.collapsible) && options.minSizePixels.value !== undefined && toValue(options.collapseThreshold) !== undefined && hasToggledDuringCurrentDrag === false) {
39-
const collapseThreshold = options.minSizePixels.value - (toValue(options.collapseThreshold) ?? 0);
40-
const expandThreshold = (toValue(options.collapseThreshold) ?? 0);
38+
if (toValue(options.collapsible) && options.minSizePixels.value !== undefined && toValue(options.collapseThreshold) !== undefined) {
39+
let threshold: number;
4140

42-
if (newPositionInPixels < collapseThreshold && collapsed.value === false) {
41+
if (thresholdLocation === 'collapse') threshold = options.minSizePixels.value - (toValue(options.collapseThreshold) ?? 0);
42+
else threshold = (toValue(options.collapseThreshold) ?? 0);
43+
44+
if (newPositionInPixels < threshold && collapsed.value === false) {
4345
collapsed.value = true;
44-
hasToggledDuringCurrentDrag = true;
4546
}
46-
else if (newPositionInPixels > expandThreshold && collapsed.value === true) {
47+
else if (newPositionInPixels > threshold && collapsed.value === true) {
4748
collapsed.value = false;
48-
hasToggledDuringCurrentDrag = true;
4949
}
5050
}
5151

@@ -66,7 +66,9 @@ export const usePointer = (collapsed: Ref<boolean>, sizePercentage: Ref<number>,
6666
});
6767

6868
watch(isDragging, (newDragging) => {
69-
if (newDragging === false) hasToggledDuringCurrentDrag = false;
69+
if (newDragging === false) {
70+
thresholdLocation = collapsed.value ? 'expand' : 'collapse';
71+
}
7072
});
7173

7274
const handleDblClick = () => {

0 commit comments

Comments
 (0)