Skip to content

Commit 694cf9d

Browse files
adamtrllzernonia
andauthored
feat: add support for handle only interaction (#71)
* feat: add support for handle only interaction * Create famous-lamps-know.md * fix: add missing handle features * test: add with handle test and related view * fix: move handleStartInteraction to pointer down listener * fix: prevent accidental close when drawer is not dismissible * chore: moved styling to component * chore: update demo --------- Co-authored-by: zernonia <[email protected]> Co-authored-by: zernonia <[email protected]>
1 parent a3ad2ca commit 694cf9d

File tree

11 files changed

+374
-4
lines changed

11 files changed

+374
-4
lines changed

.changeset/famous-lamps-know.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"vaul-vue": patch
3+
---
4+
5+
add support for handle only interaction

packages/vaul-vue/src/DrawerContent.vue

+17-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
keyboardIsOpen,
1919
closeDrawer,
2020
direction,
21+
handleOnly,
2122
} = injectDrawerRootContext()
2223
2324
const snapPointHeight = computed(() => {
@@ -45,6 +46,20 @@ function handlePointerDownOutside(event: Event) {
4546
closeDrawer()
4647
}
4748
49+
function handlePointerDown(event: PointerEvent) {
50+
if (handleOnly.value)
51+
return
52+
53+
onPress(event)
54+
}
55+
56+
function handleOnDrag(event: PointerEvent) {
57+
if (handleOnly.value)
58+
return
59+
60+
onDrag(event)
61+
}
62+
4863
watch(
4964
isOpen,
5065
(open) => {
@@ -65,8 +80,8 @@ watch(
6580
:vaul-drawer-direction="direction"
6681
:vaul-drawer-visible="isVisible ? 'true' : 'false'"
6782
:style="{ '--snap-point-height': snapPointHeight }"
68-
@pointerdown="onPress"
69-
@pointermove="onDrag"
83+
@pointerdown="handlePointerDown"
84+
@pointermove="handleOnDrag"
7085
@pointerup="onRelease"
7186
@pointer-down-outside="handlePointerDownOutside"
7287
@escape-key-down="(event) => {
+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<script setup lang="ts">
2+
import { ref } from 'vue'
3+
import { injectDrawerRootContext } from './context'
4+
import type { DrawerHandleProps } from './controls'
5+
6+
const props = withDefaults(defineProps<DrawerHandleProps>(), {
7+
preventCycle: false,
8+
})
9+
10+
const LONG_HANDLE_PRESS_TIMEOUT = 250
11+
const DOUBLE_TAP_TIMEOUT = 120
12+
13+
const { onPress, onDrag, handleRef, handleOnly, isVisible, snapPoints, activeSnapPoint, isDragging, dismissible, closeDrawer }
14+
= injectDrawerRootContext()
15+
16+
const closeTimeoutId = ref<number | null>(null)
17+
const shouldCancelInteraction = ref(false)
18+
19+
function handleStartCycle() {
20+
// Stop if this is the second click of a double click
21+
if (shouldCancelInteraction.value) {
22+
handleCancelInteraction()
23+
return
24+
}
25+
26+
window.setTimeout(() => {
27+
handleCycleSnapPoints()
28+
}, DOUBLE_TAP_TIMEOUT)
29+
}
30+
31+
function handleCycleSnapPoints() {
32+
// Prevent accidental taps while resizing drawer
33+
if (isDragging.value || props.preventCycle || shouldCancelInteraction.value) {
34+
handleCancelInteraction()
35+
return
36+
}
37+
38+
// Make sure to clear the timeout id if the user releases the handle before the cancel timeout
39+
handleCancelInteraction()
40+
41+
if (!snapPoints.value || snapPoints.value.length === 0) {
42+
if (!dismissible.value)
43+
closeDrawer()
44+
45+
return
46+
}
47+
48+
const isLastSnapPoint = activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]
49+
50+
if (isLastSnapPoint && dismissible.value) {
51+
closeDrawer()
52+
return
53+
}
54+
55+
const currentSnapIndex = snapPoints.value.findIndex(point => point === activeSnapPoint.value)
56+
57+
if (currentSnapIndex === -1)
58+
return // activeSnapPoint not found in snapPoints
59+
60+
const nextSnapPointIndex = isLastSnapPoint ? 0 : currentSnapIndex + 1
61+
62+
activeSnapPoint.value = snapPoints.value[nextSnapPointIndex]
63+
}
64+
65+
function handleStartInteraction() {
66+
closeTimeoutId.value = window.setTimeout(() => {
67+
// Cancel click interaction on a long press
68+
shouldCancelInteraction.value = true
69+
}, LONG_HANDLE_PRESS_TIMEOUT)
70+
}
71+
72+
function handleCancelInteraction() {
73+
if (closeTimeoutId.value)
74+
window.clearTimeout(closeTimeoutId.value)
75+
76+
shouldCancelInteraction.value = false
77+
}
78+
79+
function handlePointerDown(event: PointerEvent) {
80+
if (handleOnly.value)
81+
onPress(event)
82+
handleStartInteraction()
83+
}
84+
85+
function handleOnDrag(event: PointerEvent) {
86+
if (handleOnly.value)
87+
onDrag(event)
88+
}
89+
</script>
90+
91+
<template>
92+
<div
93+
ref="handleRef"
94+
:data-vaul-drawer-visible="isVisible ? 'true' : 'false'"
95+
data-vaul-handle=""
96+
aria-hidden="true"
97+
@click="handleStartCycle"
98+
@pointercancel="handleCancelInteraction"
99+
@pointerdown="handlePointerDown"
100+
@pointermove="handleOnDrag"
101+
>
102+
<span data-vaul-handle-hitarea="" aria-hidden="true">
103+
<slot />
104+
</span>
105+
</div>
106+
</template>
107+
108+
<style>
109+
[data-vaul-handle] {
110+
display: block;
111+
position: relative;
112+
opacity: .7;
113+
background: #e2e2e4;
114+
margin-left: auto;
115+
margin-right: auto;
116+
height: 5px;
117+
width: 32px;
118+
border-radius: 1rem;
119+
touch-action: pan-y;
120+
}
121+
122+
[data-vaul-handle]:active, [data-vaul-handle]:hover {
123+
opacity: 1;
124+
}
125+
126+
[data-vaul-handle-hitarea] {
127+
position: absolute;
128+
left: 50%;
129+
top: 50%;
130+
transform: translate(-50%,-50%);
131+
width: max(100%, 2.75rem);
132+
height: max(100%, 2.75rem);
133+
touch-action: inherit;
134+
}
135+
</style>

packages/vaul-vue/src/DrawerRoot.vue

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const props = withDefaults(defineProps<DrawerRootProps>(), {
2626
modal: true,
2727
scrollLockTimeout: SCROLL_LOCK_TIMEOUT,
2828
direction: 'bottom',
29+
handleOnly: false,
2930
})
3031
3132
const emit = defineEmits<DrawerRootEmits>()

packages/vaul-vue/src/context.ts

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface DrawerRootContext {
1010
isVisible: Ref<boolean>
1111
drawerRef: Ref<ComponentPublicInstance | null>
1212
overlayRef: Ref<ComponentPublicInstance | null>
13+
handleRef: Ref<ComponentPublicInstance | null>
1314
isDragging: Ref<boolean>
1415
dragStartTime: Ref<Date | null>
1516
isAllowedToDrag: Ref<boolean>
@@ -36,6 +37,7 @@ export interface DrawerRootContext {
3637
emitRelease: (open: boolean) => void
3738
emitOpenChange: (o: boolean) => void
3839
nested: Ref<boolean>
40+
handleOnly: Ref<boolean>
3941
}
4042

4143
export const [injectDrawerRootContext, provideDrawerRootContext]

packages/vaul-vue/src/controls.ts

+11
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type DrawerRootProps = {
4242
defaultOpen?: boolean
4343
nested?: boolean
4444
direction?: DrawerDirection
45+
handleOnly?: boolean
4546
} & (WithFadeFromProps | WithoutFadeFromProps)
4647

4748
export interface UseDrawerProps {
@@ -57,6 +58,7 @@ export interface UseDrawerProps {
5758
closeThreshold: Ref<number>
5859
scrollLockTimeout: Ref<number>
5960
direction: Ref<DrawerDirection>
61+
handleOnly: Ref<boolean>
6062
}
6163

6264
export interface DrawerRootEmits {
@@ -96,6 +98,10 @@ export interface Drawer {
9698
closeDrawer: () => void
9799
}
98100

101+
export interface DrawerHandleProps {
102+
preventCycle?: boolean
103+
}
104+
99105
function usePropOrDefaultRef<T>(prop: Ref<T | undefined> | undefined, defaultRef: Ref<T>): Ref<T> {
100106
return prop && !!prop.value ? (prop as Ref<T>) : defaultRef
101107
}
@@ -117,6 +123,7 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
117123
activeSnapPoint,
118124
fadeFromIndex,
119125
direction,
126+
handleOnly,
120127
} = props
121128

122129
const isOpen = ref(open.value ?? false)
@@ -149,6 +156,8 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
149156
ref<(number | string)[] | undefined>(undefined),
150157
)
151158

159+
const handleRef = ref<ComponentPublicInstance | null>(null)
160+
152161
// const onCloseProp = ref<(() => void) | undefined>(undefined)
153162
// const onOpenChangeProp = ref<((open: boolean) => void) | undefined>(undefined)
154163
// const onDragProp = ref<((event: PointerEvent, percentageDragged: number) => void) | undefined>(
@@ -676,6 +685,7 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
676685
drawerRef,
677686
drawerHeightRef,
678687
overlayRef,
688+
handleRef,
679689
isDragging,
680690
dragStartTime,
681691
isAllowedToDrag,
@@ -700,5 +710,6 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
700710
emitRelease,
701711
emitOpenChange,
702712
nested,
713+
handleOnly,
703714
}
704715
}

packages/vaul-vue/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import DrawerRoot from './DrawerRoot.vue'
22
import DrawerRootNested from './DrawerRootNested.vue'
33
import DrawerOverlay from './DrawerOverlay.vue'
44
import DrawerContent from './DrawerContent.vue'
5+
import DrawerHandle from './DrawerHandle.vue'
56

67
export type {
78
DrawerRootEmits,
@@ -18,6 +19,7 @@ export {
1819
DrawerRootNested,
1920
DrawerOverlay,
2021
DrawerContent,
22+
DrawerHandle,
2123
}
2224

2325
export {

playground/e2e/with-handle.spec.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { expect, test } from '@playwright/test'
2+
import { ANIMATION_DURATION } from './constants'
3+
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/test/with-handle')
6+
})
7+
8+
test.describe('With handle', () => {
9+
test('click should cycle to the next snap point', async ({ page }) => {
10+
await page.waitForTimeout(ANIMATION_DURATION)
11+
12+
await expect(page.getByTestId('content')).toBeVisible()
13+
await expect(page.getByTestId('active-snap-index')).toHaveText('0')
14+
15+
await page.getByTestId('handle').click()
16+
await expect(page.getByTestId('active-snap-index')).toHaveText('1')
17+
})
18+
})

playground/src/components/DemoDrawer.vue

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { DrawerContent, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger } from 'vaul-vue'
2+
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger } from 'vaul-vue'
33
</script>
44

55
<template>
@@ -15,7 +15,8 @@ import { DrawerContent, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger }
1515
class="bg-gray-100 flex flex-col rounded-t-[10px] h-full mt-24 max-h-[96%] fixed bottom-0 left-0 right-0"
1616
>
1717
<div class="p-4 bg-white rounded-t-[10px] flex-1">
18-
<div class="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-gray-300 mb-8" />
18+
<DrawerHandle data-testid="handle" class="mb-8 mt-2" />
19+
1920
<div class="max-w-md mx-auto">
2021
<h2 id="radix-:R3emdaH1:" class="font-medium mb-4">
2122
Drawer for Vue.

playground/src/router/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ const router = createRouter({
4242
path: 'without-scaled-background',
4343
component: () => import('../views/tests/WithoutScaledBackgroundView.vue'),
4444
},
45+
{
46+
path: 'with-handle',
47+
component: () => import('../views/tests/WithHandleView.vue'),
48+
},
4549
{
4650
path: 'with-scaled-background',
4751
component: () => import('../views/tests/WithScaledBackgroundView.vue'),

0 commit comments

Comments
 (0)