Skip to content

Commit 40232d3

Browse files
feat(HoverCard): add enableTouch prop for touch devices (#2687)
HoverCard ignores touch input by design (Radix parity), so it can't be opened on mobile. Add an opt-in `enableTouch` prop that lets a tap toggle the card open/closed, gated to `pointerType === 'touch'` so hover/mouse behavior is unchanged. Toggle runs on `pointerup` so a scroll gesture (which fires `pointercancel`) won't trigger it. Also clear the pending open timer in `handleDismiss` so a dismiss can't be reopened by a lingering focus/hover open timer. Resolves #1969
1 parent f76c5e1 commit 40232d3

5 files changed

Lines changed: 87 additions & 1 deletion

File tree

docs/content/meta/HoverCardRoot.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
'required': false,
1717
'default': 'false'
1818
},
19+
{
20+
'name': 'enableTouch',
21+
'description': '<p>When <code>true</code>, tapping the trigger on touch devices toggles the hover card open/closed. By default touch interactions are ignored to match pointer hover semantics.</p>\n',
22+
'type': 'boolean',
23+
'required': false,
24+
'default': 'false'
25+
},
1926
{
2027
'name': 'open',
2128
'description': '<p>The controlled open state of the hover card. Can be binded as <code>v-model:open</code>.</p>\n',
@@ -56,6 +63,7 @@
5663
| --- | --- | --- | --- | --- |
5764
| `closeDelay` | The duration from when the mouse leaves the trigger or content until the hover card closes. | `number` | No | `300` |
5865
| `defaultOpen` | The open state of the hover card when it is initially rendered. Use when you do not need to control its open state. | `boolean` | No | `false` |
66+
| `enableTouch` | When true, tapping the trigger on touch devices toggles the hover card open/closed. By default touch interactions are ignored to match pointer hover semantics. | `boolean` | No | `false` |
5967
| `open` | The controlled open state of the hover card. Can be binded as v-model:open. | `boolean` | No | - |
6068
| `openDelay` | The duration from when the mouse enters the trigger until the hover card opens. | `number` | No | `700` |
6169

packages/core/src/HoverCard/HoverCard.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,61 @@ describe('given a default HoverCard', () => {
2929
})
3030
})
3131

32+
describe('a touch tap on the trigger', () => {
33+
it('should not open the hover card by default', async () => {
34+
await wrapper.find('a').trigger('pointerup', { pointerType: 'touch' })
35+
await sleep(100)
36+
expect(wrapper.find('a').attributes('data-state')).toBe('closed')
37+
})
38+
})
39+
3240
// HoverCard mainly depends on Popper, test for Popper is not required here
3341
})
42+
43+
describe('given a HoverCard with enableTouch', () => {
44+
let wrapper: VueWrapper<InstanceType<typeof HoverCard>>
45+
globalThis.ResizeObserver = class ResizeObserver {
46+
observe() {}
47+
unobserve() {}
48+
disconnect() {}
49+
}
50+
51+
beforeEach(() => {
52+
wrapper = mount(HoverCard, { props: { enableTouch: true }, attachTo: document.body })
53+
})
54+
55+
it('should toggle open/closed on touch tap', async () => {
56+
const trigger = wrapper.find('a')
57+
58+
await trigger.trigger('pointerup', { pointerType: 'touch' })
59+
await sleep(10)
60+
expect(trigger.attributes('data-state')).toBe('open')
61+
62+
await trigger.trigger('pointerup', { pointerType: 'touch' })
63+
await sleep(10)
64+
expect(trigger.attributes('data-state')).toBe('closed')
65+
})
66+
67+
it('should ignore non-touch pointers', async () => {
68+
const trigger = wrapper.find('a')
69+
70+
await trigger.trigger('pointerup', { pointerType: 'mouse' })
71+
await sleep(10)
72+
expect(trigger.attributes('data-state')).toBe('closed')
73+
})
74+
75+
it('should not reopen from a pending focus timer after closing on touch', async () => {
76+
const trigger = wrapper.find('a')
77+
78+
// focus schedules a delayed open (openDelay), then a touch tap opens immediately
79+
await trigger.trigger('focus')
80+
await trigger.trigger('pointerup', { pointerType: 'touch' })
81+
await sleep(10)
82+
expect(trigger.attributes('data-state')).toBe('open')
83+
84+
// closing must cancel the pending open timer so it doesn't reopen
85+
await trigger.trigger('pointerup', { pointerType: 'touch' })
86+
await sleep(150)
87+
expect(trigger.attributes('data-state')).toBe('closed')
88+
})
89+
})

packages/core/src/HoverCard/HoverCardRoot.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface HoverCardRootProps {
1111
openDelay?: number
1212
/** The duration from when the mouse leaves the trigger or content until the hover card closes. */
1313
closeDelay?: number
14+
/** When `true`, tapping the trigger on touch devices toggles the hover card open/closed. By default touch interactions are ignored to match pointer hover semantics. */
15+
enableTouch?: boolean
1416
}
1517
export type HoverCardRootEmits = {
1618
/** Event handler called when the open state of the hover card changes. */
@@ -27,6 +29,7 @@ export interface HoverCardRootContext {
2729
isPointerDownOnContentRef: Ref<boolean>
2830
isPointerInTransitRef: Ref<boolean>
2931
triggerElement: Ref<HTMLElement | undefined>
32+
enableTouch: Ref<boolean>
3033
}
3134
3235
export const [injectHoverCardRootContext, provideHoverCardRootContext]
@@ -43,6 +46,7 @@ const props = withDefaults(defineProps<HoverCardRootProps>(), {
4346
open: undefined,
4447
openDelay: 700,
4548
closeDelay: 300,
49+
enableTouch: false,
4650
})
4751
const emit = defineEmits<HoverCardRootEmits>()
4852
@@ -53,7 +57,7 @@ defineSlots<{
5357
}) => any
5458
}>()
5559
56-
const { openDelay, closeDelay } = toRefs(props)
60+
const { openDelay, closeDelay, enableTouch } = toRefs(props)
5761
5862
useForwardExpose()
5963
const open = useVModel(props, 'open', emit, {
@@ -80,6 +84,7 @@ function handleClose() {
8084
}
8185
8286
function handleDismiss() {
87+
clearTimeout(openTimerRef.value)
8388
open.value = false
8489
}
8590
@@ -95,6 +100,7 @@ provideHoverCardRootContext({
95100
isPointerDownOnContentRef,
96101
isPointerInTransitRef,
97102
triggerElement,
103+
enableTouch,
98104
})
99105
</script>
100106

packages/core/src/HoverCard/HoverCardTrigger.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ function handleLeave() {
2525
}
2626
}, 0)
2727
}
28+
29+
function handleTouch(event: PointerEvent) {
30+
if (!rootContext.enableTouch.value || event.pointerType !== 'touch')
31+
return
32+
33+
if (rootContext.open.value)
34+
rootContext.onDismiss()
35+
else
36+
rootContext.onOpenChange(true)
37+
}
2838
</script>
2939

3040
<template>
@@ -40,6 +50,7 @@ function handleLeave() {
4050
data-grace-area-trigger
4151
@pointerenter="excludeTouch(rootContext.onOpen)($event)"
4252
@pointerleave="excludeTouch(handleLeave)($event)"
53+
@pointerup="handleTouch"
4354
@focus="rootContext.onOpen()"
4455
@blur="rootContext.onClose()"
4556
>

packages/core/src/HoverCard/story/_HoverCard.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@ import {
88
HoverCardTrigger,
99
} from '..'
1010
11+
withDefaults(defineProps<{ enableTouch?: boolean }>(), {
12+
enableTouch: false,
13+
})
14+
1115
const hoverState = ref(false)
1216
</script>
1317

1418
<template>
1519
<HoverCardRoot
1620
v-model:open="hoverState"
1721
:open-delay="100"
22+
:enable-touch="enableTouch"
1823
>
1924
<HoverCardTrigger
2025
class="inline-block cursor-pointer rounded-full shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] outline-none focus:shadow-[0_0_0_2px_white]"

0 commit comments

Comments
 (0)