Skip to content

Commit 47f85b4

Browse files
committed
feat: close tooltip on Esc
1 parent 60dd561 commit 47f85b4

5 files changed

Lines changed: 209 additions & 42 deletions

File tree

.changeset/sixty-doors-obey.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@vingy/vueltip": minor
3+
---
4+
5+
feat: close tooltip on `Esc`

packages/vueltip/README.md

Lines changed: 27 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -202,51 +202,36 @@ Define your own theme by setting CSS variables:
202202

203203
### Plugin Options
204204

205-
The `vueltipPlugin` accepts the following options:
206-
207-
| Option | Type | Default | Description |
208-
| -------------------------- | -------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
209-
| `component` | `Component` | Required | The Vue component to render as the tooltip |
210-
| `showDelay` | `number` | `0` | Delay in milliseconds before the tooltip appears on hover |
211-
| `hideDelay` | `number` | `200` | Delay in milliseconds before the tooltip disappears when the cursor leaves |
212-
| `defaultPlacement` | `Placement` | `'top'` | Default tooltip placement: `'top'`, `'bottom'`, `'left'`, `'right'`, etc. |
213-
| `defaultTruncateDetection` | `'x' \| 'y' \| 'both' \| 'none'` | `'both'` | Direction(s) to check for text truncation (`'x'` for horizontal, `'y'` for vertical, `'both'`, or `'none'` to disable) |
214-
| `handleDialogModals` | `boolean` | `false` | Whether to handle tooltips within HTML `<dialog>` elements with the `open` attribute (modal dialogs) |
215-
| `placementAttribute` | `string` | `'data-vueltip-placement'` | HTML attribute name for tooltip placement overrides |
216-
| `keyAttribute` | `string` | `'data-vueltip-key'` | HTML attribute name for tooltip identification |
217-
| `truncateAttribute` | `string` | `'data-vueltip-truncate'` | HTML attribute name for truncate detection overrides |
218-
219-
### useVueltip Composable Options
220-
221-
The `useVueltip` composable accepts the following options:
222-
223-
| Option | Type | Default | Description |
224-
| ----------------- | -------------------------- | -------- | ---------------------------------------------------------- |
225-
| `tooltipElement` | `Ref<HTMLElement \| null>` | Required | Reference to the tooltip container element |
226-
| `arrowElement` | `Ref<HTMLElement \| null>` | Optional | Reference to the arrow element for positioning |
227-
| `offset` | `number` | `0` | Offset distance between the tooltip and the target element |
228-
| `padding` | `number` | `0` | Padding between the tooltip and the viewport edges |
229-
| `arrowSize` | `number` | `0` | Size of the arrow element (used for proper positioning) |
230-
| `floatingOptions` | `UseFloatingOptions` | `{}` | Advanced options for the underlying Floating UI library |
205+
| Option | Type | Default | Description |
206+
| -------------------------- | -------------------------------- | -------------------------- | ------------------------------------------------------------------ |
207+
| `component` | `Component` | Required | The Vue component to render as the tooltip |
208+
| `showDelay` | `number` | `0` | Delay in ms before the tooltip appears |
209+
| `hideDelay` | `number` | `200` | Delay in ms before the tooltip disappears |
210+
| `defaultPlacement` | `Placement` | `'top'` | Default placement: `'top'`, `'bottom'`, `'left'`, `'right'`, etc. |
211+
| `defaultTruncateDetection` | `'x' \| 'y' \| 'both' \| 'none'` | `'both'` | Axis to check for text truncation |
212+
| `handleDialogModals` | `boolean` | `false` | Move the tooltip inside `<dialog>` elements when they are modal |
213+
| `placementAttribute` | `string` | `'data-vueltip-placement'` | HTML attribute name for per-element placement overrides |
214+
| `keyAttribute` | `string` | `'data-vueltip-key'` | HTML attribute name used to identify tooltip targets |
215+
| `truncateAttribute` | `string` | `'data-vueltip-truncate'` | HTML attribute name for per-element truncation detection overrides |
216+
217+
### useVueltip Options
218+
219+
| Option | Type | Default | Description |
220+
| ----------------- | -------------------------- | -------- | ---------------------------------------------------- |
221+
| `tooltipElement` | `Ref<HTMLElement \| null>` | Required | Reference to the tooltip container element |
222+
| `arrowElement` | `Ref<HTMLElement \| null>` || Reference to the arrow element for positioning |
223+
| `offset` | `number` | `0` | Distance between the tooltip and the target element |
224+
| `padding` | `number` | `0` | Minimum space between the tooltip and viewport edges |
225+
| `arrowSize` | `number` | `0` | Size of the arrow element |
226+
| `floatingOptions` | `UseFloatingOptions` | `{}` | Advanced options passed to Floating UI |
231227

232228
### Directive Options
233229

234-
The `v-tooltip` directive accepts bindings in two formats:
235-
236-
**Simple string (text only):**
237-
238-
```ts
239-
v-tooltip="'Tooltip text'"
240-
```
241-
242-
**Object with options:**
243-
244-
```ts
245-
v-tooltip="{
246-
text: 'Tooltip text',
247-
placement: 'right' // Placement: 'top', 'bottom', 'left', 'right', etc.
248-
}"
249-
```
230+
| Option | Type | Default | Description |
231+
| ----------- | ----------- | ------- | -------------------------------------------------------- |
232+
| `text` | `string` || Tooltip text |
233+
| `placement` | `Placement` | `'top'` | Placement override for this element |
234+
| `custom` | `object` || Arbitrary typed data accessible in the tooltip component |
250235

251236
## Custom Data
252237

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
import { onKeydown } from './keyboardListeners'
4+
import { setOptions } from './options'
5+
import {
6+
debouncedHoveredElement,
7+
hoveredElement,
8+
tooltipKey,
9+
} from './state'
10+
11+
const setup = () => {
12+
vi.useFakeTimers()
13+
setOptions({ showDelay: 0, hideDelay: 0 })
14+
const el = document.createElement('div')
15+
document.body.appendChild(el)
16+
hoveredElement.value = undefined
17+
debouncedHoveredElement.value = undefined
18+
tooltipKey.value = undefined
19+
return el
20+
}
21+
22+
const teardown = (el: HTMLElement) => {
23+
vi.useRealTimers()
24+
el.remove()
25+
hoveredElement.value = undefined
26+
debouncedHoveredElement.value = undefined
27+
tooltipKey.value = undefined
28+
}
29+
30+
describe('onKeydown', () => {
31+
it('clears hoveredElement when Escape is pressed and a tooltip is active', () => {
32+
const el = setup()
33+
hoveredElement.value = el
34+
35+
onKeydown(
36+
new KeyboardEvent('keydown', {
37+
key: 'Escape',
38+
bubbles: true,
39+
}),
40+
)
41+
42+
expect(hoveredElement.value).toBeUndefined()
43+
44+
teardown(el)
45+
})
46+
47+
it('does nothing when Escape is pressed but no tooltip is hovered', () => {
48+
const el = setup()
49+
hoveredElement.value = undefined
50+
51+
onKeydown(
52+
new KeyboardEvent('keydown', {
53+
key: 'Escape',
54+
bubbles: true,
55+
}),
56+
)
57+
58+
expect(hoveredElement.value).toBeUndefined()
59+
60+
teardown(el)
61+
})
62+
63+
it('does not dismiss the tooltip when a different key is pressed', () => {
64+
const el = setup()
65+
hoveredElement.value = el
66+
67+
onKeydown(
68+
new KeyboardEvent('keydown', {
69+
key: 'Tab',
70+
bubbles: true,
71+
}),
72+
)
73+
74+
expect(hoveredElement.value).toBe(el)
75+
76+
teardown(el)
77+
})
78+
79+
it('clears debouncedHoveredElement synchronously on Escape — no timer needed', () => {
80+
const el = setup()
81+
tooltipKey.value = 'test-key'
82+
hoveredElement.value = el
83+
debouncedHoveredElement.value = el
84+
85+
onKeydown(
86+
new KeyboardEvent('keydown', {
87+
key: 'Escape',
88+
bubbles: true,
89+
}),
90+
)
91+
92+
expect(hoveredElement.value).toBeUndefined()
93+
expect(debouncedHoveredElement.value).toBeUndefined()
94+
95+
teardown(el)
96+
})
97+
98+
it('clears tooltip when Escape is pressed while mouse is over the tooltip element', () => {
99+
const el = setup()
100+
const tooltipEl = document.createElement('div')
101+
document.body.appendChild(tooltipEl)
102+
103+
// Simulate: user hovered the trigger, tooltip appeared, then moused over
104+
// the tooltip itself. composables.ts sets hoveredElement = debouncedHoveredElement
105+
// when the tooltip is entered, so both point to the original trigger.
106+
debouncedHoveredElement.value = el
107+
hoveredElement.value = el
108+
109+
onKeydown(
110+
new KeyboardEvent('keydown', {
111+
key: 'Escape',
112+
bubbles: true,
113+
}),
114+
)
115+
116+
// Both must be cleared synchronously so the tooltip's own mouseenter
117+
// listener cannot re-assert hoveredElement against a stale debouncedHoveredElement.
118+
expect(hoveredElement.value).toBeUndefined()
119+
expect(debouncedHoveredElement.value).toBeUndefined()
120+
121+
tooltipEl.remove()
122+
teardown(el)
123+
})
124+
})
125+
126+
describe('window keydown integration', () => {
127+
it('clears hoveredElement when Escape is dispatched on window', () => {
128+
const el = setup()
129+
hoveredElement.value = el
130+
131+
window.addEventListener('keydown', onKeydown)
132+
window.dispatchEvent(
133+
new KeyboardEvent('keydown', {
134+
key: 'Escape',
135+
bubbles: true,
136+
}),
137+
)
138+
window.removeEventListener('keydown', onKeydown)
139+
140+
expect(hoveredElement.value).toBeUndefined()
141+
142+
teardown(el)
143+
})
144+
145+
it('does not clear hoveredElement when a non-Escape key is dispatched on window', () => {
146+
const el = setup()
147+
hoveredElement.value = el
148+
149+
window.addEventListener('keydown', onKeydown)
150+
window.dispatchEvent(
151+
new KeyboardEvent('keydown', {
152+
key: 'Tab',
153+
bubbles: true,
154+
}),
155+
)
156+
window.removeEventListener('keydown', onKeydown)
157+
158+
expect(hoveredElement.value).toBe(el)
159+
160+
teardown(el)
161+
})
162+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {
2+
debouncedHoveredElement,
3+
forceClearHoveredElement,
4+
hoveredElement,
5+
} from './state'
6+
7+
export const onKeydown = (e: KeyboardEvent) => {
8+
if (e.key !== 'Escape') return
9+
const el =
10+
hoveredElement.value ?? debouncedHoveredElement.value
11+
if (el) forceClearHoveredElement(el)
12+
}

packages/vueltip/src/plugin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { App, Component, createVNode, render } from 'vue'
22

3+
import { onKeydown } from './keyboardListeners'
34
import { setOptions } from './options'
45
import type { Options } from './types'
56

@@ -24,6 +25,8 @@ export const vueltipPlugin = {
2425
setOptions(rest)
2526
if (!component) return
2627

28+
window.addEventListener('keydown', onKeydown)
29+
2730
const container = getContainer()
2831

2932
const vnode = createVNode(component)

0 commit comments

Comments
 (0)