Skip to content

Commit 27cb5a5

Browse files
feat: add support for snap points (#19)
1 parent 347ecff commit 27cb5a5

File tree

6 files changed

+183
-1
lines changed

6 files changed

+183
-1
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script lang="ts" setup>
2+
import { SplitPanel } from '@directus/vue-split-panel';
3+
</script>
4+
5+
<template>
6+
<SplitPanel class="w-full" :snap-points="[25, 50]">
7+
<template #start>
8+
<div class="h-16 bg-orange-50 flex items-center justify-center">Panel A</div>
9+
</template>
10+
11+
<template #end>
12+
<div class="h-16 bg-blue-50 flex items-center justify-center">Panel B</div>
13+
</template>
14+
</SplitPanel>
15+
</template>

docs/content/1.getting-started/2.usage.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@ import { SplitPanel } from '@directus/vue-split-panel';
111111
CSS timing function for the collapse transition.
112112
Defaults to `"cubic-bezier(0.4, 0, 0.6, 1)"`
113113
::
114+
115+
::field{name="snapPoints" type="number[]"}
116+
Where to snap the primary panel to during dragging operations.
117+
Defaults to `[]`
118+
::
119+
120+
::field{name="snapThreshold" type="number"}
121+
How close the divider must be to a snap point for snapping to occur.
122+
Defaults to `12`
123+
::
114124
::
115125

116126
## Examples
@@ -427,6 +437,33 @@ import { SplitPanel } from '@directus/vue-split-panel';
427437
```
428438
::
429439

440+
### Snapping
441+
442+
To snap the divider to a given point while dragging, pass an array of points in the `snapPoints` property.
443+
444+
::code-preview
445+
:example-snap
446+
447+
#code
448+
```vue
449+
<script lang="ts" setup>
450+
import { SplitPanel } from '@directus/vue-split-panel';
451+
</script>
452+
453+
<template>
454+
<SplitPanel class="w-full" :snap-points="[25, 50]">
455+
<template #start>
456+
<div class="h-16 bg-orange-50 flex items-center justify-center">Panel A</div>
457+
</template>
458+
459+
<template #end>
460+
<div class="h-16 bg-blue-50 flex items-center justify-center">Panel B</div>
461+
</template>
462+
</SplitPanel>
463+
</template>
464+
```
465+
::
466+
430467
## Accessibility
431468

432469
Uses the [Window Splitter WAI-ARIA pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter).

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { SplitPanel } from '../../src';
33
</script>
44

55
<template>
6-
<SplitPanel id="panels-root" divider-hit-area="50px" orientation="vertical">
6+
<SplitPanel id="panels-root" :snap-points="[25, 50]">
77
<template #start>
88
<div id="a" class="panel">
99
Panel A

packages/vue-split-panel/src/SplitPanel.vue

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,19 @@ export interface SplitPanelProps {
3838
3939
/** CSS transition timing function for the collapse transition */
4040
transitionTimingFunctionCollapse?: string;
41+
42+
/** What size values the divider should snap to */
43+
snapPoints?: number[];
44+
45+
/** How close to the snap point the size should be before the snapping occurs */
46+
snapThreshold?: number;
4147
}
4248
</script>
4349

4450
<script lang="ts" setup>
4551
import { clamp, useDraggable, useElementSize, useResizeObserver } from '@vueuse/core';
4652
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
53+
import { closestNumber } from './utils/closest-number';
4754
import { percentageToPixels } from './utils/percentage-to-pixels';
4855
import { pixelsToPercentage } from './utils/pixels-to-percentage';
4956
@@ -58,6 +65,8 @@ const props = withDefaults(defineProps<SplitPanelProps>(), {
5865
transitionDuration: '0',
5966
transitionTimingFunctionCollapse: 'cubic-bezier(0.4, 0, 0.6, 1)',
6067
transitionTimingFunctionExpand: 'cubic-bezier(0, 0, 0.2, 1)',
68+
snapPoints: () => [],
69+
snapThreshold: 12,
6170
});
6271
6372
const panelEl = useTemplateRef('split-panel');
@@ -123,6 +132,11 @@ const maxSizePercentage = computed(() => {
123132
return pixelsToPercentage(componentSize.value, props.maxSize);
124133
});
125134
135+
const snapPixels = computed(() => {
136+
if (props.sizeUnit === 'px') return props.snapPoints;
137+
return props.snapPoints.map((snapPercentage) => percentageToPixels(componentSize.value, snapPercentage));
138+
});
139+
126140
let expandedSizePercentage = 0;
127141
128142
/** Whether the primary column is collapsed or not */
@@ -177,6 +191,19 @@ watch([dividerX, dividerY], ([newX, newY]) => {
177191
}
178192
}
179193
194+
for (let snapPoint of snapPixels.value) {
195+
if (props.direction === 'rtl' && props.orientation === 'horizontal') {
196+
snapPoint = componentSize.value - snapPoint;
197+
}
198+
199+
if (
200+
newPositionInPixels >= snapPoint - props.snapThreshold
201+
&& newPositionInPixels <= snapPoint + props.snapThreshold
202+
) {
203+
newPositionInPixels = snapPoint;
204+
}
205+
}
206+
180207
sizePercentage.value = clamp(pixelsToPercentage(componentSize.value, newPositionInPixels), 0, 100);
181208
});
182209
@@ -237,6 +264,14 @@ const handleKeydown = (event: KeyboardEvent) => {
237264
}
238265
};
239266
267+
const handleDblClick = () => {
268+
const closest = closestNumber(snapPixels.value, sizePixels.value);
269+
270+
if (closest !== undefined) {
271+
sizePixels.value = closest;
272+
}
273+
};
274+
240275
const gridTemplate = computed(() => {
241276
let primary: string;
242277
@@ -293,6 +328,7 @@ defineExpose({ collapse, expand, toggle });
293328
aria-valuemax="100"
294329
aria-label="Resize"
295330
@keydown.prevent="handleKeydown"
331+
@dblclick="handleDblClick"
296332
>
297333
<slot name="divider">
298334
<div />
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { closestNumber } from './closest-number';
3+
4+
describe('closestNumber', () => {
5+
it('returns undefined for empty array', () => {
6+
expect(closestNumber([], 10)).toBeUndefined();
7+
});
8+
9+
it('returns the only element for single-item array', () => {
10+
expect(closestNumber([5], 10)).toBe(5);
11+
});
12+
13+
it('finds the exact match when present', () => {
14+
expect(closestNumber([1, 5, 10, 15], 10)).toBe(10);
15+
});
16+
17+
it('returns closest lower value when tie broken by smaller number', () => {
18+
// distance to 9: |8-9|=1, |10-9|=1 => choose 8
19+
expect(closestNumber([8, 10], 9)).toBe(8);
20+
});
21+
22+
it('works with negative numbers', () => {
23+
expect(closestNumber([-10, -3, 2, 5], -4)).toBe(-3);
24+
});
25+
26+
it('handles large numbers', () => {
27+
expect(closestNumber([1e9, 1e12], 5e11)).toBe(1e9); // distances: 4.99e11 vs 5e11
28+
});
29+
30+
it('ignores NaN and Infinity values', () => {
31+
// Only finite numbers 5 and 20 considered => closest to 12 is 5 (distance 7 vs 8)
32+
expect(closestNumber([Number.NaN, 5, Infinity, -Infinity, 20], 12)).toBe(5);
33+
});
34+
35+
it('returns undefined if all values are non-finite', () => {
36+
expect(closestNumber([Number.NaN, Infinity, -Infinity], 3)).toBeUndefined();
37+
});
38+
39+
it('handles target being negative infinity', () => {
40+
expect(closestNumber([-100, 0, 100], Number.NEGATIVE_INFINITY)).toBe(-100);
41+
});
42+
43+
it('handles target being positive infinity', () => {
44+
expect(closestNumber([-100, 0, 100], Number.POSITIVE_INFINITY)).toBe(100);
45+
});
46+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Returns the number from an array that is closest to the provided value.
3+
*
4+
* Tie breaking:
5+
* - For finite target values: if two numbers are equally close, the smaller numeric value is returned (stable + predictable)
6+
* - For target = +Infinity: the largest candidate wins (intuitive 'towards' the target)
7+
* - For target = -Infinity: the smallest candidate wins
8+
*
9+
* Non-finite (NaN / ±Infinity) entries in the candidate list are ignored. If, after filtering, no numbers remain, `undefined` is returned.
10+
*
11+
* @param numbers - The list of candidate numbers
12+
* @param value - The target value to compare against
13+
* @returns The closest number from the list, or `undefined` if the list is empty or only contained non-finite values
14+
*/
15+
export const closestNumber = (numbers: readonly number[], value: number): number | undefined => {
16+
let closest: number | undefined;
17+
let smallestDiff = Number.POSITIVE_INFINITY;
18+
19+
for (const n of numbers) {
20+
if (!Number.isFinite(n)) continue; // ignore NaN / Infinity
21+
const diff = Math.abs(n - value);
22+
23+
if (diff < smallestDiff) {
24+
smallestDiff = diff;
25+
closest = n;
26+
continue;
27+
}
28+
29+
if (diff === smallestDiff && closest !== undefined) {
30+
if (value === Number.POSITIVE_INFINITY) {
31+
if (n > closest) closest = n;
32+
}
33+
else if (value === Number.NEGATIVE_INFINITY) {
34+
if (n < closest) closest = n;
35+
}
36+
else if (n < closest) {
37+
closest = n; // finite target: choose smaller
38+
}
39+
}
40+
41+
if (closest === undefined) {
42+
closest = n;
43+
smallestDiff = diff;
44+
}
45+
}
46+
47+
return closest;
48+
};

0 commit comments

Comments
 (0)