Skip to content

[#2142] touch events handling #3150

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

Draft
wants to merge 8 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/docs/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -4890,8 +4890,8 @@
"text": "You can place dropdown any direction you want. Offset is calculated relative to `placement` prop. We use `main` and `cross` offset. Means `main` will be relative to `placement` direction while `cross` is perpendicular. `auto` placement will be `bottom` by default, but will change to `top` if it is not possible to display on bottom."
},
"trigger": {
"title": "Trigger",
"text": "You can use `click`, `hover` or `none` trigger which will open dropdown. If you want dropdown to be opened only programmatically use `none` trigger"
"title": "Триггер",
"text": "Триггерами (свойство `trigger`) для раскрытия компонента могут служить события `click`, `dblclick`, `right-click` или `hover`. Если вы не хотите, чтобы компонент раскрывался через пользовательское взаимодействие, используйте значение `none`."
},
"cursor": {
"title": "Place on cursor",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<va-select
v-model="placementWIthAlias"
:options="placements"
style="--va-form-element-default-width: 100%; width: 100%;"
/>
</td>
</tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,29 @@
<va-dropdown-content> Dropped down! </va-dropdown-content>
</va-dropdown>

<va-dropdown trigger="dblclick">
<template #anchor>
<va-button class="mr-2">
Double click
</va-button>
</template>

<va-dropdown-content> Dropped down! </va-dropdown-content>
</va-dropdown>

<va-dropdown trigger="hover">
<template #anchor>
<va-button> Hover </va-button>
<va-button class="max-sm:mt-2 max-sm:mr-2">
Hover
</va-button>
</template>

<va-dropdown-content> Dropped down! </va-dropdown-content>
</va-dropdown>

<div class="mt-2">
<div
class="mt-2 max-sm:inline-block max-sm:leading-none max-sm:align-middle"
>
<va-dropdown
v-model="doShowDropdown"
trigger="none"
Expand All @@ -54,3 +68,11 @@ export default {
},
};
</script>

<style scoped>
.va-button {
--va-button-line-height: var(--va-dropdown-line-height);

vertical-align: middle;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"trigger": {
"title": "Trigger",
"text": "You can use `click`, `hover` or `none` trigger which will open dropdown. If you want dropdown to be opened only programmatically use `none` trigger"
"text": "You can use `click`, `hover`, `dblclick` or `right-click` trigger to open dropdown. If you want dropdown to be opened only programmatically, use `none` trigger."
},
"cursor": {
"title": "Place on cursor",
Expand Down
49 changes: 49 additions & 0 deletions packages/ui/src/components/va-dropdown/VaDropdown.demo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,54 @@
</va-dropdown>
</div>
</VbCard>

<VbCard title="Trigger">
<div style="display: flex; gap: 1.5rem; align-items: flex-start;">
<va-dropdown>
<template #anchor>
Default
</template>

Dropdown text
</va-dropdown>
<va-dropdown trigger="right-click">
<template #anchor>
Right click
</template>

Dropdown text
</va-dropdown>
<va-dropdown trigger="dblclick">
<template #anchor>
Double click
</template>

Dropdown text
</va-dropdown>
<va-dropdown trigger="hover">
<template #anchor>
Hover
</template>

Dropdown text
</va-dropdown>
<va-dropdown
v-model="isTriggerDropdownShown"
trigger="none"
class="mr-2"
:close-on-click-outside="false"
>
<template #anchor>
None
</template>

Dropdown text
</va-dropdown>
<va-button @click="isTriggerDropdownShown = !isTriggerDropdownShown">
{{ isTriggerDropdownShown ? "hide" : "show" }} dropdown
</va-button>
</div>
</VbCard>
</VbDemo>
</template>

Expand Down Expand Up @@ -523,6 +571,7 @@ export default {
redrawContentSize: 100,
anchorDefaultValue: true,
keepAnchorWidth: true,
isTriggerDropdownShown: false,
}
},

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/va-dropdown/VaDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export default defineComponent({
ariaLabel: { type: String, default: '$t:toggleDropdown' },
},

emits: [...useStatefulEmits, 'anchor-click', 'anchor-right-click', 'content-click', 'click-outside', 'close', 'open'],
emits: [...useStatefulEmits, 'anchor-click', 'anchor-right-click', 'anchor-dblclick', 'content-click', 'click-outside', 'close', 'open'],

setup (props, { emit, slots, attrs }) {
const contentRef = shallowRef<HTMLElement>()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Ref } from 'vue'
import { useEvent } from '../../../composables'

import { useEvent, useTouch } from '../../../composables'

const isTyping = (e: Event) => {
const target = e.target as HTMLElement
Expand Down Expand Up @@ -30,9 +31,15 @@ export const useKeyboardNavigation = (anchorRef: Ref<HTMLElement | undefined>, i

type MouseEventName = 'mouseleave' | 'mouseenter' | 'click' | 'dblclick' | 'contextmenu'
export const useMouseNavigation = (
anchorRef: Ref<HTMLElement | undefined>,
listeners: Record<MouseEventName, (e: MouseEvent) => any>,
anchorRef: Ref<HTMLElement>,
listeners: Record<MouseEventName, (e: TouchEvent | MouseEvent) => void>,
) => {
useTouch(anchorRef, {
short: listeners.click,
long: listeners.contextmenu,
double: listeners.dblclick,
})

useEvent(['click', 'contextmenu', 'dblclick'], (e: MouseEvent) => {
if (isTyping(e)) { return }

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export * from './useSyncProp'
export * from './useTemplateRef'
export * from './useTextColor'
export * from './useTimer'
export * from './useTouch'
export * from './useTrackBy'
export * from './useTranslation'
export * from './useTrapFocus'
Expand Down
41 changes: 32 additions & 9 deletions packages/ui/src/composables/useEvent.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { Ref, unref, watch } from 'vue'

import { useWindow } from './useWindow'

type MaybeRef<T> = Ref<T> | T

type UseEventEventName = keyof GlobalEventHandlersEventMap | string[]
type UseEventEvent<N extends UseEventEventName, D> = N extends keyof GlobalEventHandlersEventMap ? GlobalEventHandlersEventMap[N] : D
type EventTarget = MaybeRef<GlobalEventHandlers | undefined | null>
type GlobalEventNames = keyof GlobalEventHandlersEventMap
type UseEventEventName = GlobalEventNames | string
type UseEventEventNames = GlobalEventNames | string[]
type UseEventEvent<N extends UseEventEventNames, D> = N extends GlobalEventNames ? GlobalEventHandlersEventMap[N] : D

/**
* SSR safety listen to target event.
* @param target by default window
* @param listener event callback.
* @param target by default window.
* @param event if string, listener will be fully typed. If array of string, you need to type event manually.
*
*
Expand All @@ -18,23 +23,41 @@ type UseEventEvent<N extends UseEventEventName, D> = N extends keyof GlobalEvent
* useEvent(['mousedown', 'mouseup', 'mousemove'], (e) => {})
* ```
*/
export const useEvent = <N extends UseEventEventName, E extends Event>(
export const useEvent = <N extends UseEventEventNames, E extends Event>(
event: N,
listener: (this: GlobalEventHandlers, event: UseEventEvent<N, E>) => any,
target?: MaybeRef<GlobalEventHandlers | undefined | null> | boolean,
target?: EventTarget | boolean,
) => {
const source = target && typeof target !== 'boolean' ? target : useWindow()
const capture = typeof target === 'boolean' ? target : false

const addEventListener = (target: EventTarget, event: UseEventEventName) => {
unref(target)?.addEventListener(event, listener as any, capture)
}

const removeEventListener = (target: EventTarget, event: UseEventEventName) => {
unref(target)?.removeEventListener(event, listener as any, capture)
}

watch(source, (newValue, oldValue) => {
if (!Array.isArray(event)) {
unref(newValue)?.addEventListener(event, listener as any, capture)
unref(oldValue)?.removeEventListener(event, listener as any, capture)
addEventListener(newValue, event)
removeEventListener(oldValue, event)
} else {
event.forEach((e) => {
unref(newValue)?.addEventListener(e, listener as any, capture)
unref(oldValue)?.removeEventListener(e, listener as any, capture)
addEventListener(newValue, e)
removeEventListener(oldValue, e)
})
}
}, { immediate: true })

const removeListeners = () => {
if (!Array.isArray(event)) {
removeEventListener(source, event)
} else {
event.forEach((e) => removeEventListener(source, e))
}
}

return { removeListeners }
}
87 changes: 87 additions & 0 deletions packages/ui/src/composables/useTouch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { ref, onBeforeUnmount, type Ref } from 'vue'

import { useEvent, useWindow } from './'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer direct import by path to prevent circular dependecies...


type UseTouchCallback = (e: TouchEvent) => void
type UseTouchCallbacks = { short: UseTouchCallback, long: UseTouchCallback, double?: UseTouchCallback }

/**
* Provides touch events handling.
*
* TODO: make a part of useMouseEvent (implement it first) hook.
*
* @param target - element that will be listened for touch start event.
* @param callbacks - callbacks for short, long and double touch events.
* @param ignoreDoubleClicks - if true, double clicks will be ignored.
* @param timings - timings for long and double touch events.
*
* @example
* ```ts
* useTouch(target, {
* short: () => {},
* long: () => {},
* double: () => {},
* ignoreDoubleClicks: true,
* })
* ```
*/
export const useTouch = (
target: Ref<HTMLElement>,
callbacks: UseTouchCallbacks,
ignoreDoubleClicks = false,
timings = { long: 500, double: 250 },
) => {
const window = useWindow()

if (!window.value || !('ontouchstart' in window.value)) { return }

let previousTouchTime = 0
let shortTouchTimer: ReturnType<typeof setTimeout> | undefined

const onTouchStart = (event: TouchEvent) => {
// preventing same (click, dblclick, contextmenu) mouse events to be triggered
event.preventDefault()
event.stopPropagation()

const longTouchTimer = setTimeout(() => {
callbacks.long(event)

removeTouchCancelListeners()
}, timings.long)

const clearTouchTimeouts = () => {
if (longTouchTimer) { clearTimeout(longTouchTimer) }
if (shortTouchTimer) { clearTimeout(shortTouchTimer) }
}

const cancelTouch = () => {
clearTouchTimeouts()
removeTouchCancelListeners()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useEvent must be used outside of onTouchStart function and there will no be needed to add removeTouchCancelListeners...


// if long touch callback is released, short/double won't be called because of removeTouchCancelListeners
if (ignoreDoubleClicks) {
callbacks.short(event)

return
}

const isDoubleClick = previousTouchTime && (Date.now() - previousTouchTime < timings.double)
if (isDoubleClick) {
callbacks.double && callbacks.double(event)

previousTouchTime = 0
} else {
shortTouchTimer = setTimeout(() => {
callbacks.short(event)
}, timings.double)

previousTouchTime = Date.now()
}
}

const { removeListeners: removeTouchCancelListeners } = useEvent(['touchmove', 'touchend', 'touchcancel'], cancelTouch, true)
}

const { removeListeners: removeTouchStartListeners } = useEvent('touchstart', onTouchStart, target)
onBeforeUnmount(removeTouchStartListeners)
}