vue-pointing-bubble 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +417 -2
- package/dist/index.d.ts +44 -0
- package/dist/vue-pointing-bubble.js +310 -0
- package/dist/vue-pointing-bubble.umd.cjs +9 -0
- package/package.json +63 -4
- package/registry/ui/pointing-bubble/PointingBubble.vue +408 -0
- package/registry/ui/pointing-bubble/index.ts +1 -0
- package/src/index.d.ts +44 -0
- package/src/index.js +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexey Murz Korepov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,418 @@
|
|
|
1
|
-
#
|
|
1
|
+
# vue-pointing-bubble
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Vue 3 callout for a speech bubble, speech balloon, popover with arrow,
|
|
4
|
+
annotated popover, or coachmark. The tail tip points **exactly** at an absolute
|
|
5
|
+
`(x, y)` coordinate or at the center of a DOM element.
|
|
6
|
+
|
|
7
|
+
The box is a regular element that you style with CSS. The tail is a small SVG
|
|
8
|
+
triangle in the same colors that covers the box border where it attaches, so there
|
|
9
|
+
is no seam, and its tip lands on the target pixel with zero offset.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
**[Live demo](https://murznn.github.io/vue-pointing-bubble/)**
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- Point at absolute coordinates (`{ x, y }`) or at a DOM element / component instance.
|
|
18
|
+
- Automatic quadrant placement that keeps the bubble inside the container and the visible viewport, a fixed quadrant, or a box positioned by your own CSS.
|
|
19
|
+
- Seamless tail with rounded box corners and an optional drop shadow that covers both.
|
|
20
|
+
- Any content through the default slot. The bubble resizes to fit it.
|
|
21
|
+
- Style the bubble with regular `class` and `style`, including Tailwind CSS or UnoCSS utility classes: size, padding, position, background, border, corner radius, and `z-index`.
|
|
22
|
+
- Glides smoothly to a new target, and to a new size when its content changes.
|
|
23
|
+
- Follows window resize, scrolling, and container resize.
|
|
24
|
+
- No CSS file to import and no dependency on Tailwind or any other CSS framework.
|
|
25
|
+
- Ships as an ES module, a UMD bundle for `<script>` tags, the raw `.vue` SFC, and a [shadcn-vue](https://www.shadcn-vue.com/docs/registry) registry item. Includes TypeScript declarations.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install vue-pointing-bubble
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
You can also install it straight from GitHub:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install github:MurzNN/vue-pointing-bubble
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Vue `^3.3` is a peer dependency.
|
|
40
|
+
|
|
41
|
+
### shadcn-vue
|
|
42
|
+
|
|
43
|
+
This repository is a shadcn-vue registry. In a project that already has `components.json`, install the component with:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pnpm dlx shadcn-vue@latest add MurzNN/vue-pointing-bubble/pointing-bubble
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The CLI reads [`registry.json`](registry.json) from the default branch and writes the component into the project's UI directory. With the default alias, that is `src/components/ui/pointing-bubble`:
|
|
50
|
+
|
|
51
|
+
```vue
|
|
52
|
+
<script setup>
|
|
53
|
+
import { PointingBubble } from '@/components/ui/pointing-bubble'
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<PointingBubble :target="{ x: 320, y: 180 }">
|
|
58
|
+
Look here.
|
|
59
|
+
</PointingBubble>
|
|
60
|
+
</template>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Append `#ref` to install from a branch, tag, or commit, for example `MurzNN/vue-pointing-bubble/pointing-bubble#v0.1.0`.
|
|
64
|
+
|
|
65
|
+
The same item is also published with the demo, so you can register a short name in `components.json`:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"registries": {
|
|
70
|
+
"@vue-pointing-bubble": "https://murznn.github.io/vue-pointing-bubble/r/{name}.json"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pnpm dlx shadcn-vue@latest add @vue-pointing-bubble/pointing-bubble
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
List what the repository publishes with `pnpm dlx shadcn-vue@latest search MurzNN/vue-pointing-bubble`.
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
The component fills its nearest **positioned** ancestor (`position: relative`,
|
|
84
|
+
`absolute`, or `fixed`) with an overlay. Coordinates are measured relative to
|
|
85
|
+
that ancestor.
|
|
86
|
+
|
|
87
|
+
### Pointing at page coordinates
|
|
88
|
+
|
|
89
|
+
To point at absolute coordinates on the page, wrap the bubble in a layer that
|
|
90
|
+
covers the page. Because the layer is `position: absolute`, it takes no space in
|
|
91
|
+
the layout, so the page content stays where it is. Add `pointer-events: none` so the
|
|
92
|
+
layer doesn't block clicks on the elements under it.
|
|
93
|
+
|
|
94
|
+
```vue
|
|
95
|
+
<script setup>
|
|
96
|
+
import { PointingBubble } from 'vue-pointing-bubble'
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<template>
|
|
100
|
+
<div style="position: absolute; inset: 0; pointer-events: none">
|
|
101
|
+
<PointingBubble :target="{ x: 320, y: 180 }">
|
|
102
|
+
<strong>Look here!</strong>
|
|
103
|
+
<p>The tail tip is exactly at (320, 180) on the page.</p>
|
|
104
|
+
</PointingBubble>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The coordinates are measured from the top-left corner of the document, the same way
|
|
110
|
+
as `event.pageX` and `event.pageY`, and the bubble scrolls together with the page.
|
|
111
|
+
This works only if none of the layer's ancestors is positioned. Otherwise, the
|
|
112
|
+
coordinates are measured from that ancestor. To be safe, render the layer with
|
|
113
|
+
`<Teleport to="body">`.
|
|
114
|
+
|
|
115
|
+
If you want viewport coordinates instead (`event.clientX` and `event.clientY`), with
|
|
116
|
+
the bubble staying in place while the page scrolls, use `position: fixed` for the
|
|
117
|
+
layer.
|
|
118
|
+
|
|
119
|
+
### Pointing at coordinates inside a container
|
|
120
|
+
|
|
121
|
+
To point at a spot inside a specific element, such as an image or a chart, put the
|
|
122
|
+
bubble into a positioned container. The coordinates are then measured from the
|
|
123
|
+
container's top-left corner, so they stay correct wherever the container is on the
|
|
124
|
+
page.
|
|
125
|
+
|
|
126
|
+
```vue
|
|
127
|
+
<template>
|
|
128
|
+
<div style="position: relative; width: 600px">
|
|
129
|
+
<img src="/floor-plan.png" width="600" height="400" alt="Floor plan">
|
|
130
|
+
|
|
131
|
+
<PointingBubble :target="{ x: 420, y: 150 }">
|
|
132
|
+
The kitchen is here.
|
|
133
|
+
</PointingBubble>
|
|
134
|
+
</div>
|
|
135
|
+
</template>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Here, `(420, 150)` is a point on the image. If the image moves, for example because
|
|
139
|
+
of text added above it, the bubble moves with it.
|
|
140
|
+
|
|
141
|
+
### Pointing at an element
|
|
142
|
+
|
|
143
|
+
Pass a template ref, either a DOM element or a component instance. The tip
|
|
144
|
+
attaches to the center of the element. This is the usual setup for a coachmark
|
|
145
|
+
or a popover with arrow next to a control.
|
|
146
|
+
|
|
147
|
+
```vue
|
|
148
|
+
<script setup>
|
|
149
|
+
import { ref } from 'vue'
|
|
150
|
+
import { PointingBubble } from 'vue-pointing-bubble'
|
|
151
|
+
|
|
152
|
+
const button = ref(null)
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
<template>
|
|
156
|
+
<div style="position: relative; height: 400px">
|
|
157
|
+
<button ref="button">Save</button>
|
|
158
|
+
|
|
159
|
+
<PointingBubble :target="button" placement="bottom-right">
|
|
160
|
+
Click here to save your changes.
|
|
161
|
+
</PointingBubble>
|
|
162
|
+
</div>
|
|
163
|
+
</template>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
By default the tip lands on the element's center. To stop it at the element's
|
|
167
|
+
border instead, so it doesn't cover the button's label, add `target-anchor="edge"`:
|
|
168
|
+
|
|
169
|
+
```vue
|
|
170
|
+
<PointingBubble :target="button" target-anchor="edge">
|
|
171
|
+
Click here to save your changes.
|
|
172
|
+
</PointingBubble>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
With automatic placement, the tip then touches the middle of the element's top or
|
|
176
|
+
bottom edge, whichever faces the box. With `placement="manual"`, it touches the
|
|
177
|
+
point of the element's border nearest to the center of the box.
|
|
178
|
+
|
|
179
|
+
You can also pass a CSS selector instead of a ref, which is handy when the element
|
|
180
|
+
belongs to another component or isn't rendered by Vue at all:
|
|
181
|
+
|
|
182
|
+
```vue
|
|
183
|
+
<PointingBubble target="#save-button" target-anchor="edge">
|
|
184
|
+
Click here to save your changes.
|
|
185
|
+
</PointingBubble>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The selector is looked up in the whole document with `document.querySelector`, and
|
|
189
|
+
the first match is used. It's looked up again on every update, so the bubble finds
|
|
190
|
+
an element that appears later, after a re-render, resize, or scroll, or when you
|
|
191
|
+
call `update()`. While nothing matches, the bubble is hidden.
|
|
192
|
+
|
|
193
|
+
### Sizing and styling the box
|
|
194
|
+
|
|
195
|
+
`class`, `style`, and event listeners that you put on `<PointingBubble>` go to the
|
|
196
|
+
box with your content, the same way as on a regular `<div>`. The tail is attached
|
|
197
|
+
to whatever size the box ends up with.
|
|
198
|
+
|
|
199
|
+
```vue
|
|
200
|
+
<PointingBubble :target="button" class="w-64 p-3 text-sm">
|
|
201
|
+
Click here to save your changes.
|
|
202
|
+
</PointingBubble>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
By default, the box fits its content, wraps the text at `maxWidth` (280px), and has
|
|
206
|
+
`padding` of 8px, a white background, a gray border, and 8px rounded corners.
|
|
207
|
+
These defaults come from a tiny stylesheet that the component adds to the page,
|
|
208
|
+
with zero specificity, so anything your classes or `style` set wins, including
|
|
209
|
+
zero values like `p-0` or `rounded-none`.
|
|
210
|
+
|
|
211
|
+
If you give the box a fixed height, content that doesn't fit is clipped. Add
|
|
212
|
+
`overflow-auto` (or `overflow: auto`) to make it scrollable instead.
|
|
213
|
+
|
|
214
|
+
The box paints itself, so any CSS works on it: gradients, per-corner radii,
|
|
215
|
+
`hover:` variants, and so on. The tail takes these properties from the box:
|
|
216
|
+
|
|
217
|
+
| CSS of the box | Effect on the tail |
|
|
218
|
+
| --------------------------------------- | ----------------------------------------- |
|
|
219
|
+
| `background-color` | Fill color. |
|
|
220
|
+
| `border-top-width`, `border-top-color` | Outline width and color. |
|
|
221
|
+
| `border-top-left-radius` | Keeps the tail away from rounded corners. |
|
|
222
|
+
| `z-index` | Stacking order of the whole bubble (`30` if unset). |
|
|
223
|
+
|
|
224
|
+
```vue
|
|
225
|
+
<PointingBubble :target="button" class="p-3 rounded-xl bg-yellow-100 border-2 border-yellow-600">
|
|
226
|
+
Click here to save your changes.
|
|
227
|
+
</PointingBubble>
|
|
228
|
+
|
|
229
|
+
<!-- The same without a CSS framework -->
|
|
230
|
+
<PointingBubble
|
|
231
|
+
:target="button"
|
|
232
|
+
style="padding: 12px; border-radius: 12px; background: #fef9c3; border: 2px solid #ca8a04"
|
|
233
|
+
>
|
|
234
|
+
Click here to save your changes.
|
|
235
|
+
</PointingBubble>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The tail is a solid color, so with a gradient or background image, pick a
|
|
239
|
+
`background-color` that blends with it. The tail is updated on a re-render and on
|
|
240
|
+
resize. If the box colors change without one, for example on hover or when you
|
|
241
|
+
toggle dark mode, call `update()`.
|
|
242
|
+
|
|
243
|
+
The `shadow` prop adds a drop shadow that follows the box and the tail together. A
|
|
244
|
+
`box-shadow` of your own only applies to the box.
|
|
245
|
+
|
|
246
|
+
### Placing the box yourself
|
|
247
|
+
|
|
248
|
+
By default, the box is placed next to the tip automatically. With
|
|
249
|
+
`placement="manual"`, you position the box yourself with `left` and `top`, set by
|
|
250
|
+
classes or by `style`, relative to the positioned parent. The tail still points
|
|
251
|
+
exactly at `target`, from whichever side of the box faces it. This works well for
|
|
252
|
+
an annotated popover on a diagram, or for labels the user can drag around.
|
|
253
|
+
|
|
254
|
+
```vue
|
|
255
|
+
<template>
|
|
256
|
+
<div style="position: relative; height: 300px">
|
|
257
|
+
<img ref="photo" src="/laptop.png" style="margin: 80px auto; display: block">
|
|
258
|
+
|
|
259
|
+
<PointingBubble :target="photo" placement="manual" class="left-4 top-4 w-44">
|
|
260
|
+
This label stays in the top-left corner.
|
|
261
|
+
</PointingBubble>
|
|
262
|
+
</div>
|
|
263
|
+
</template>
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Any CSS unit works, for example `class="left-[7em] top-[5em]"` in Tailwind CSS, or
|
|
267
|
+
`class="left-7em top-5em"` in UnoCSS. Without a framework, use `style`:
|
|
268
|
+
|
|
269
|
+
```vue
|
|
270
|
+
<PointingBubble :target="photo" placement="manual" :style="{ left: x + 'px', top: y + 'px' }">
|
|
271
|
+
Drag me around.
|
|
272
|
+
</PointingBubble>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
You don't need a class like `absolute`, because the box is always absolutely
|
|
276
|
+
positioned. Without `left` and `top`, it sits in the top-left corner of the parent.
|
|
277
|
+
The width works as in automatic mode: the box fits its content and wraps at
|
|
278
|
+
`max-width` unless you set a width. In manual mode, the box itself doesn't glide
|
|
279
|
+
when you move it, only the tail does. If the box moves without a resize or a change
|
|
280
|
+
of its classes or style, such as during a CSS transition, call `update()`.
|
|
281
|
+
|
|
282
|
+
### A box without a tail
|
|
283
|
+
|
|
284
|
+
Leave out `target`, or set it to `null`, to draw just the box, with no tail. The
|
|
285
|
+
box is then a regular element in the normal document flow: it isn't absolutely
|
|
286
|
+
positioned and doesn't need a positioned parent. It sits on its own line inside
|
|
287
|
+
the component's root block and is as wide as its content, up to the parent's
|
|
288
|
+
width. The 280px default `maxWidth` doesn't apply here, but an explicit
|
|
289
|
+
`:max-width`, or a width or max-width from your CSS, does. Margins on the box work as usual, for example `mx-auto` to
|
|
290
|
+
center it. Opacity and transforms set on the component's root element, such as
|
|
291
|
+
by Slidev's `v-click`, apply to the box as well. In a flex or grid parent, the
|
|
292
|
+
root is the flex or grid item, so put item classes like `flex-1` on a wrapper
|
|
293
|
+
element. This is handy for a step that doesn't point at anything, such as the
|
|
294
|
+
welcome step of a guided tour:
|
|
295
|
+
|
|
296
|
+
```vue
|
|
297
|
+
<PointingBubble :target="step.element" class="mt-4">
|
|
298
|
+
{{ step.text }}
|
|
299
|
+
</PointingBubble>
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
When `target` changes from `null` to an element, the box leaves the flow and appears
|
|
303
|
+
next to the element, with a tail, and comes back when it changes to `null`. To hide
|
|
304
|
+
the bubble completely, use `v-if`. Margins only apply to this mode and to
|
|
305
|
+
`placement="manual"`; a bubble placed automatically next to its target ignores them.
|
|
306
|
+
|
|
307
|
+
### Global registration
|
|
308
|
+
|
|
309
|
+
```js
|
|
310
|
+
import { createApp } from 'vue'
|
|
311
|
+
import VuePointingBubble from 'vue-pointing-bubble'
|
|
312
|
+
import App from './App.vue'
|
|
313
|
+
|
|
314
|
+
createApp(App)
|
|
315
|
+
.use(VuePointingBubble) // registers <PointingBubble>
|
|
316
|
+
// .use(VuePointingBubble, { name: 'Callout' }) // or under a custom name
|
|
317
|
+
.mount('#app')
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The component is also exported under the alias `Callout`:
|
|
321
|
+
|
|
322
|
+
```js
|
|
323
|
+
import { Callout } from 'vue-pointing-bubble'
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Using the raw SFC
|
|
327
|
+
|
|
328
|
+
If you prefer to compile the component with your own build pipeline:
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
import PointingBubble from 'vue-pointing-bubble/PointingBubble.vue'
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Without a build step (CDN)
|
|
335
|
+
|
|
336
|
+
```html
|
|
337
|
+
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|
338
|
+
<script src="https://unpkg.com/vue-pointing-bubble"></script>
|
|
339
|
+
|
|
340
|
+
<div id="app">
|
|
341
|
+
<div style="position: absolute; inset: 0; pointer-events: none">
|
|
342
|
+
<pointing-bubble :target="{ x: 200, y: 120 }">Hello!</pointing-bubble>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
<script>
|
|
347
|
+
Vue.createApp({})
|
|
348
|
+
.component('PointingBubble', VuePointingBubble.PointingBubble)
|
|
349
|
+
.mount('#app')
|
|
350
|
+
</script>
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Props
|
|
354
|
+
|
|
355
|
+
| Prop | Type | Default | Description |
|
|
356
|
+
| -------------------- | ------------------------------------- | ----------- | ----------- |
|
|
357
|
+
| `target` | `{ x, y }` \| `Element` \| component \| `string` | `null` | What the tip points at. Coordinates are relative to the positioned parent. A string is a CSS selector, looked up with `document.querySelector`; while it matches nothing, the bubble is hidden. Elements are pointed at their center, or at their border with `targetAnchor="edge"`. When `null`, only the box is drawn, without a tail, as a regular element in the document flow. Use `v-if` to hide the bubble entirely. |
|
|
358
|
+
| `placement` | `'auto'` \| `'top-left'` \| `'top-right'` \| `'bottom-left'` \| `'bottom-right'` \| `'manual'` | `'auto'` | Where the box sits relative to the tip. `auto` chooses the quadrant facing the center of the container. It flips to the other side if the bubble would go outside the viewport, such as after scrolling, and fits on the other side. `manual` leaves the position to the `left` and `top` that you set with `class` or `style`. The tail then grows from whichever side of the box faces the tip. |
|
|
359
|
+
| `targetAnchor` | `'center'` \| `'edge'` | `'center'` | For element targets, where the tip lands. `center` is the element's center. `edge` is the element's border facing the box: the middle of the top or bottom edge with automatic placement, or the nearest border point with `manual` placement. Ignored for coordinate targets. |
|
|
360
|
+
| `maxWidth` | `number` | `280` | Width in px at which content wraps, unless your `class` or `style` sets a width or max-width. Without a `target`, there's no default: the box is only limited by its parent's width. |
|
|
361
|
+
| `tailLength` | `number` | `45` | Horizontal and vertical offset in px from the tip to the box corner. |
|
|
362
|
+
| `tailBaseWidth` | `number` | `24` | Width in px of the tail where it joins the box. |
|
|
363
|
+
| `padding` | `number` \| `string` | `8` | Padding of the box (a number means px), unless your `class` or `style` sets a padding. |
|
|
364
|
+
| `shadow` | `boolean` | `true` | Whether to render a drop shadow under the box and the tail. |
|
|
365
|
+
| `transitionDuration` | `number` | `200` | Duration in ms of the glide animation when the target, size, or placement changes. The box and the tail move together. `0` disables it, and it is skipped when the user prefers reduced motion. Consider `0` while the target follows the mouse, such as during dragging. |
|
|
366
|
+
|
|
367
|
+
## Slots
|
|
368
|
+
|
|
369
|
+
| Slot | Props | Description |
|
|
370
|
+
| --------- | ----------------------- | ----------- |
|
|
371
|
+
| `default` | `{ placement, tip }` | Bubble content. `placement` is the resolved quadrant, `'manual'`, or `'none'` without a target, and `tip` is the tip position `{ x, y }` in container pixels, or `null` without a target. |
|
|
372
|
+
|
|
373
|
+
## Exposed methods
|
|
374
|
+
|
|
375
|
+
| Name | Description |
|
|
376
|
+
| ---------- | ----------- |
|
|
377
|
+
| `update()` | Recomputes the tip position, remeasures the box, and rereads its colors for the tail. Call it if the target element or a manually placed box moved, or the box colors changed, without a window resize, scroll, or re-render, such as after an animation or a theme switch. |
|
|
378
|
+
|
|
379
|
+
## Styling
|
|
380
|
+
|
|
381
|
+
The overlay elements have stable class names you can target from your own CSS:
|
|
382
|
+
|
|
383
|
+
- `.vue-pointing-bubble`: the root element, with a `data-placement` attribute holding the resolved quadrant. With a `target`, it's the full-size overlay; without one, it's a plain block around the box, with `data-placement="none"`.
|
|
384
|
+
- `.vue-pointing-bubble__inner`: the box with the slot content. It receives the `class`, `style`, and listeners from `<PointingBubble>`.
|
|
385
|
+
- `.vue-pointing-bubble__svg`: the SVG layer with the tail. It isn't rendered without a `target`.
|
|
386
|
+
- `.vue-pointing-bubble__path`: the tail path.
|
|
387
|
+
|
|
388
|
+
## Development
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
npm install
|
|
392
|
+
npm run dev:demo # start the demo dev server with hot reload (`npm run dev` is an alias)
|
|
393
|
+
npm run build # build the library into dist/
|
|
394
|
+
npm run registry:build # write the shadcn-vue registry JSON into demo/public/r/
|
|
395
|
+
npm run build:demo # build the registry JSON and the demo into demo-dist/
|
|
396
|
+
npm run preview:demo # serve the built demo
|
|
397
|
+
npm test # build the demo and run the browser tests against it
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
The tests in [`tests/demo.spec.js`](tests/demo.spec.js) use
|
|
401
|
+
[Playwright](https://playwright.dev/) and drive the demo page itself: they hover
|
|
402
|
+
the hint buttons, walk through the tour, click the chart, and drag the labels,
|
|
403
|
+
checking that each tail tip lands exactly on its target and that the box without a
|
|
404
|
+
target stays in the page flow. They run in the installed Google Chrome; if you
|
|
405
|
+
don't have it, install it with `npx playwright install chrome`.
|
|
406
|
+
|
|
407
|
+
The component source is [`registry/ui/pointing-bubble/PointingBubble.vue`](registry/ui/pointing-bubble/PointingBubble.vue). The library and the shadcn-vue registry install the same file. The demo (`demo/Demo.vue`) imports it through `src/index.js`, so editing the demo or the component hot-reloads in the browser. It shows hover hints,
|
|
408
|
+
a guided tour with a coachmark, an annotated popover on a picture, and draggable
|
|
409
|
+
labels with custom box positions.
|
|
410
|
+
|
|
411
|
+
The workflow [`.github/workflows/deploy-demo.yml`](.github/workflows/deploy-demo.yml)
|
|
412
|
+
runs the tests on every pull request and push to `main`, and on `main`, once they
|
|
413
|
+
pass, deploys the demo to GitHub Pages. To enable it, open **Settings → Pages** in the repository and
|
|
414
|
+
set **Source** to **GitHub Actions**.
|
|
415
|
+
|
|
416
|
+
## License
|
|
417
|
+
|
|
418
|
+
[MIT](LICENSE)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { DefineComponent, Plugin, ComponentPublicInstance } from 'vue'
|
|
2
|
+
|
|
3
|
+
export type PointingBubblePlacement =
|
|
4
|
+
| 'auto'
|
|
5
|
+
| 'top-left'
|
|
6
|
+
| 'top-right'
|
|
7
|
+
| 'bottom-left'
|
|
8
|
+
| 'bottom-right'
|
|
9
|
+
| 'manual'
|
|
10
|
+
|
|
11
|
+
export interface PointingBubblePoint {
|
|
12
|
+
x: number
|
|
13
|
+
y: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PointingBubbleTarget =
|
|
17
|
+
| PointingBubblePoint
|
|
18
|
+
| Element
|
|
19
|
+
| ComponentPublicInstance
|
|
20
|
+
/** A CSS selector, looked up with `document.querySelector`. */
|
|
21
|
+
| string
|
|
22
|
+
| null
|
|
23
|
+
|
|
24
|
+
export interface PointingBubbleProps {
|
|
25
|
+
target?: PointingBubbleTarget
|
|
26
|
+
placement?: PointingBubblePlacement
|
|
27
|
+
targetAnchor?: 'center' | 'edge'
|
|
28
|
+
maxWidth?: number
|
|
29
|
+
tailLength?: number
|
|
30
|
+
tailBaseWidth?: number
|
|
31
|
+
padding?: number | string
|
|
32
|
+
shadow?: boolean
|
|
33
|
+
transitionDuration?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export declare const PointingBubble: DefineComponent<PointingBubbleProps>
|
|
37
|
+
export declare const Callout: typeof PointingBubble
|
|
38
|
+
|
|
39
|
+
export interface PointingBubblePluginOptions {
|
|
40
|
+
name?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
declare const plugin: Plugin<[PointingBubblePluginOptions?]>
|
|
44
|
+
export default plugin
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { computed as e, createCommentVNode as t, createElementBlock as n, createElementVNode as r, mergeProps as i, normalizeStyle as a, onBeforeUnmount as o, onMounted as s, onUpdated as c, openBlock as l, ref as u, renderSlot as d, watch as f } from "vue";
|
|
2
|
+
//#region registry/ui/pointing-bubble/PointingBubble.vue
|
|
3
|
+
var p = ["data-placement"], m = ["d", "fill"], h = [
|
|
4
|
+
"d",
|
|
5
|
+
"stroke",
|
|
6
|
+
"stroke-width"
|
|
7
|
+
], g = ":where(.vue-pointing-bubble__inner) {\n box-sizing: border-box;\n width: var(--vpb-width, max-content);\n max-width: var(--vpb-max-width, none);\n padding: var(--vpb-padding);\n background: white;\n border: 2px solid gray;\n border-radius: 8px;\n}", _ = "drop-shadow(0 6px 8px rgba(0, 0, 0, 0.25))", v = 30, y = 280, b = [
|
|
8
|
+
"tx",
|
|
9
|
+
"ty",
|
|
10
|
+
"bx",
|
|
11
|
+
"by"
|
|
12
|
+
], x = ["tx", "ty"], S = () => {
|
|
13
|
+
if (typeof document > "u" || document.getElementById("vue-pointing-bubble-styles")) return;
|
|
14
|
+
let e = document.createElement("style");
|
|
15
|
+
e.id = "vue-pointing-bubble-styles", e.textContent = g, document.head.prepend(e);
|
|
16
|
+
}, C = /*@__PURE__*/ Object.assign({ inheritAttrs: !1 }, {
|
|
17
|
+
__name: "PointingBubble",
|
|
18
|
+
props: {
|
|
19
|
+
target: {
|
|
20
|
+
type: [
|
|
21
|
+
Object,
|
|
22
|
+
String,
|
|
23
|
+
typeof Element < "u" ? Element : Object
|
|
24
|
+
],
|
|
25
|
+
default: null
|
|
26
|
+
},
|
|
27
|
+
placement: {
|
|
28
|
+
type: String,
|
|
29
|
+
default: "auto",
|
|
30
|
+
validator: (e) => [
|
|
31
|
+
"auto",
|
|
32
|
+
"top-left",
|
|
33
|
+
"top-right",
|
|
34
|
+
"bottom-left",
|
|
35
|
+
"bottom-right",
|
|
36
|
+
"manual"
|
|
37
|
+
].includes(e)
|
|
38
|
+
},
|
|
39
|
+
targetAnchor: {
|
|
40
|
+
type: String,
|
|
41
|
+
default: "center",
|
|
42
|
+
validator: (e) => ["center", "edge"].includes(e)
|
|
43
|
+
},
|
|
44
|
+
maxWidth: {
|
|
45
|
+
type: Number,
|
|
46
|
+
default: void 0
|
|
47
|
+
},
|
|
48
|
+
tailLength: {
|
|
49
|
+
type: Number,
|
|
50
|
+
default: 45
|
|
51
|
+
},
|
|
52
|
+
tailBaseWidth: {
|
|
53
|
+
type: Number,
|
|
54
|
+
default: 24
|
|
55
|
+
},
|
|
56
|
+
padding: {
|
|
57
|
+
type: [Number, String],
|
|
58
|
+
default: 8
|
|
59
|
+
},
|
|
60
|
+
shadow: {
|
|
61
|
+
type: Boolean,
|
|
62
|
+
default: !0
|
|
63
|
+
},
|
|
64
|
+
transitionDuration: {
|
|
65
|
+
type: Number,
|
|
66
|
+
default: 200
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
setup(g, { expose: C }) {
|
|
70
|
+
let w = g;
|
|
71
|
+
S();
|
|
72
|
+
let T = u(null), E = u(null), D = u(null), O = u(null), k = u(null), A = u({
|
|
73
|
+
left: 0,
|
|
74
|
+
top: 0,
|
|
75
|
+
width: 0,
|
|
76
|
+
height: 0,
|
|
77
|
+
scaleX: 1,
|
|
78
|
+
scaleY: 1,
|
|
79
|
+
viewportWidth: 0,
|
|
80
|
+
viewportHeight: 0
|
|
81
|
+
}), j = e(() => w.placement === "manual"), M = (e, t) => !!e && !!t && Object.keys(e).every((n) => e[n] === t[n]), N = (e) => typeof Element > "u" || !e ? null : typeof e == "string" ? document.querySelector(e) : e instanceof Element ? e : e.$el instanceof Element ? e.$el : null, P = () => {
|
|
82
|
+
let e = T.value;
|
|
83
|
+
if (!w.target || !e) {
|
|
84
|
+
D.value = null;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
let t = e.getBoundingClientRect(), n = e.offsetWidth ? t.width / e.offsetWidth : 1, r = e.offsetHeight ? t.height / e.offsetHeight : 1;
|
|
88
|
+
A.value = {
|
|
89
|
+
left: t.left,
|
|
90
|
+
top: t.top,
|
|
91
|
+
width: e.offsetWidth,
|
|
92
|
+
height: e.offsetHeight,
|
|
93
|
+
scaleX: n,
|
|
94
|
+
scaleY: r,
|
|
95
|
+
viewportWidth: window.innerWidth,
|
|
96
|
+
viewportHeight: window.innerHeight
|
|
97
|
+
};
|
|
98
|
+
let i = N(w.target);
|
|
99
|
+
if (i) {
|
|
100
|
+
let e = i.getBoundingClientRect(), a = {
|
|
101
|
+
left: (e.left - t.left) / n,
|
|
102
|
+
top: (e.top - t.top) / r,
|
|
103
|
+
right: (e.right - t.left) / n,
|
|
104
|
+
bottom: (e.bottom - t.top) / r
|
|
105
|
+
};
|
|
106
|
+
M(a, O.value) || (O.value = a), D.value = {
|
|
107
|
+
x: (a.left + a.right) / 2,
|
|
108
|
+
y: (a.top + a.bottom) / 2
|
|
109
|
+
};
|
|
110
|
+
} else typeof w.target.x == "number" && typeof w.target.y == "number" ? (O.value = null, D.value = {
|
|
111
|
+
x: w.target.x,
|
|
112
|
+
y: w.target.y
|
|
113
|
+
}) : (O.value = null, D.value = null);
|
|
114
|
+
F();
|
|
115
|
+
};
|
|
116
|
+
f(() => [
|
|
117
|
+
w.target,
|
|
118
|
+
w.target?.x,
|
|
119
|
+
w.target?.y
|
|
120
|
+
], P, { flush: "post" });
|
|
121
|
+
let F = () => {
|
|
122
|
+
let e = E.value;
|
|
123
|
+
if (!e || !w.target) return;
|
|
124
|
+
let t = getComputedStyle(e), n = {
|
|
125
|
+
x: j.value ? e.offsetLeft : 0,
|
|
126
|
+
y: j.value ? e.offsetTop : 0,
|
|
127
|
+
width: e.offsetWidth,
|
|
128
|
+
height: e.offsetHeight,
|
|
129
|
+
fill: t.backgroundColor,
|
|
130
|
+
stroke: t.borderTopColor,
|
|
131
|
+
strokeWidth: parseFloat(t.borderTopWidth) || 0,
|
|
132
|
+
radius: t.borderTopLeftRadius,
|
|
133
|
+
zIndex: t.zIndex
|
|
134
|
+
};
|
|
135
|
+
M(n, k.value) || (k.value = n);
|
|
136
|
+
};
|
|
137
|
+
c(F);
|
|
138
|
+
let I = e(() => {
|
|
139
|
+
let e = k.value;
|
|
140
|
+
if (!D.value || !e?.width || !e?.height) return null;
|
|
141
|
+
let { width: t, height: n } = e, { x: r, y: i } = D.value, a = w.tailLength, o = A.value, s = w.targetAnchor === "edge" ? O.value : null;
|
|
142
|
+
if (j.value) {
|
|
143
|
+
if (s) {
|
|
144
|
+
let r = e.x + t / 2, i = e.y + n / 2;
|
|
145
|
+
if (!(r >= s.left && r <= s.right && i >= s.top && i <= s.bottom)) return {
|
|
146
|
+
placement: "manual",
|
|
147
|
+
tx: Math.min(s.right, Math.max(s.left, r)),
|
|
148
|
+
ty: Math.min(s.bottom, Math.max(s.top, i)),
|
|
149
|
+
bx: e.x,
|
|
150
|
+
by: e.y,
|
|
151
|
+
w: t,
|
|
152
|
+
h: n
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
placement: "manual",
|
|
157
|
+
tx: r,
|
|
158
|
+
ty: i,
|
|
159
|
+
bx: e.x,
|
|
160
|
+
by: e.y,
|
|
161
|
+
w: t,
|
|
162
|
+
h: n
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
let c = (e) => s ? e === "top" ? s.top : s.bottom : i, l = w.placement;
|
|
166
|
+
if (l === "auto") {
|
|
167
|
+
let e = i > (o.height || 600) / 2 ? "top" : "bottom", s = r > (o.width || 800) / 2 ? "left" : "right", { scaleX: u, scaleY: d } = o;
|
|
168
|
+
if (o.viewportHeight) {
|
|
169
|
+
let t = o.top + (c("top") - n - a) * d >= 0, r = o.top + (c("bottom") + n + a) * d <= o.viewportHeight;
|
|
170
|
+
e === "top" && !t && r ? e = "bottom" : e === "bottom" && !r && t && (e = "top");
|
|
171
|
+
}
|
|
172
|
+
if (o.viewportWidth) {
|
|
173
|
+
let e = o.left + (r - t - a) * u >= 0, n = o.left + (r + t + a) * u <= o.viewportWidth;
|
|
174
|
+
s === "left" && !e && n ? s = "right" : s === "right" && !n && e && (s = "left");
|
|
175
|
+
}
|
|
176
|
+
l = `${e}-${s}`;
|
|
177
|
+
}
|
|
178
|
+
let [u, d] = l.split("-"), f = c(u);
|
|
179
|
+
return {
|
|
180
|
+
placement: l,
|
|
181
|
+
tx: r,
|
|
182
|
+
ty: f,
|
|
183
|
+
bx: d === "left" ? r - t - a : r + a,
|
|
184
|
+
by: u === "top" ? f - n - a : f + a,
|
|
185
|
+
w: t,
|
|
186
|
+
h: n
|
|
187
|
+
};
|
|
188
|
+
}), L = u(null), R = 0, z = () => typeof matchMedia < "u" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
189
|
+
f(I, (e) => {
|
|
190
|
+
typeof cancelAnimationFrame < "u" && cancelAnimationFrame(R);
|
|
191
|
+
let t = L.value, n = w.transitionDuration;
|
|
192
|
+
if (!e || !t || n <= 0 || typeof requestAnimationFrame > "u" || z()) {
|
|
193
|
+
L.value = e;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
let r = performance.now(), i = (a) => {
|
|
197
|
+
let o = Math.min(1, (a - r) / n), s = 1 - (1 - o) ** 3, c = { ...e }, l = e.placement === "manual" ? x : b;
|
|
198
|
+
for (let n of l) c[n] = t[n] + (e[n] - t[n]) * s;
|
|
199
|
+
L.value = c, o < 1 && (R = requestAnimationFrame(i));
|
|
200
|
+
};
|
|
201
|
+
R = requestAnimationFrame(i);
|
|
202
|
+
}, { immediate: !0 });
|
|
203
|
+
let B = null;
|
|
204
|
+
s(() => {
|
|
205
|
+
window.addEventListener("resize", P), window.addEventListener("scroll", P, !0), typeof ResizeObserver < "u" && (B = new ResizeObserver(P), B.observe(T.value), B.observe(E.value)), P();
|
|
206
|
+
}), o(() => {
|
|
207
|
+
window.removeEventListener("resize", P), window.removeEventListener("scroll", P, !0), B?.disconnect(), typeof cancelAnimationFrame < "u" && cancelAnimationFrame(R);
|
|
208
|
+
});
|
|
209
|
+
let V = e(() => {
|
|
210
|
+
let e = L.value;
|
|
211
|
+
if (!e) return null;
|
|
212
|
+
let { placement: t, tx: n, ty: r, bx: i, by: a, w: o, h: s } = e, c = k.value.strokeWidth, l = k.value.radius, u = parseFloat(l) * (l.endsWith("%") ? Math.min(o, s) / 100 : 1) || 0, d = w.tailBaseWidth / 2, f = (e, t, n) => t > n ? (t + n) / 2 : Math.min(n, Math.max(t, e)), p = null, m = 0;
|
|
213
|
+
if (t === "manual") {
|
|
214
|
+
let e = Math.max(i - n, n - (i + o), 0), t = Math.max(a - r, r - (a + s), 0);
|
|
215
|
+
if (e > 0 && e >= t) p = n < i ? "left" : "right", m = r;
|
|
216
|
+
else if (t > 0) p = r < a ? "top" : "bottom", m = n;
|
|
217
|
+
else return null;
|
|
218
|
+
} else {
|
|
219
|
+
let [e, n] = t.split("-");
|
|
220
|
+
p = n === "left" ? "right" : "left", m = a + s * (e === "top" ? .65 : .35);
|
|
221
|
+
}
|
|
222
|
+
let h = (e, t) => `${e} ${t}`, g = (e, t) => `M ${h(...e)} L ${n} ${r} L ${h(...t)}`, _ = c / 2;
|
|
223
|
+
if (p === "left" || p === "right") {
|
|
224
|
+
let e = f(m, a + u + d, a + s - u - d), t = e - d, n = e + d, r = p === "left" ? i + c + 1 : i + o - c - 1, l = p === "left" ? i + _ : i + o - _;
|
|
225
|
+
return {
|
|
226
|
+
fill: g([r, t], [r, n]),
|
|
227
|
+
stroke: c ? g([l, t], [l, n]) : null
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
let v = f(m, i + u + d, i + o - u - d), y = v - d, b = v + d, x = p === "top" ? a + c + 1 : a + s - c - 1, S = p === "top" ? a + _ : a + s - _;
|
|
231
|
+
return {
|
|
232
|
+
fill: g([y, x], [b, x]),
|
|
233
|
+
stroke: c ? g([y, S], [b, S]) : null
|
|
234
|
+
};
|
|
235
|
+
}), H = e(() => w.target ? L.value?.placement : "none"), U = e(() => {
|
|
236
|
+
if (!w.target) return {};
|
|
237
|
+
let e = k.value?.zIndex;
|
|
238
|
+
return {
|
|
239
|
+
position: "absolute",
|
|
240
|
+
inset: 0,
|
|
241
|
+
pointerEvents: "none",
|
|
242
|
+
zIndex: e && e !== "auto" ? e : v,
|
|
243
|
+
filter: w.shadow ? _ : void 0
|
|
244
|
+
};
|
|
245
|
+
}), W = e(() => {
|
|
246
|
+
let e = { "--vpb-padding": typeof w.padding == "number" ? `${w.padding}px` : w.padding }, t = w.maxWidth ?? (w.target ? y : null);
|
|
247
|
+
if (t != null && (e["--vpb-max-width"] = t + "px"), !w.target) return e["--vpb-width"] = "fit-content", w.shadow && (e.filter = _), e;
|
|
248
|
+
let n = L.value;
|
|
249
|
+
return Object.assign(e, {
|
|
250
|
+
position: "absolute",
|
|
251
|
+
pointerEvents: "auto",
|
|
252
|
+
visibility: n ? "visible" : "hidden"
|
|
253
|
+
}), j.value || Object.assign(e, {
|
|
254
|
+
left: (n?.bx ?? 0) + "px",
|
|
255
|
+
top: (n?.by ?? 0) + "px",
|
|
256
|
+
margin: 0
|
|
257
|
+
}), e;
|
|
258
|
+
});
|
|
259
|
+
return C({ update: P }), (e, o) => (l(), n("div", {
|
|
260
|
+
ref_key: "rootRef",
|
|
261
|
+
ref: T,
|
|
262
|
+
class: "vue-pointing-bubble",
|
|
263
|
+
"data-placement": H.value,
|
|
264
|
+
style: a(U.value)
|
|
265
|
+
}, [r("div", i({
|
|
266
|
+
ref_key: "boxRef",
|
|
267
|
+
ref: E,
|
|
268
|
+
class: "vue-pointing-bubble__inner"
|
|
269
|
+
}, e.$attrs, { style: W.value }), [d(e.$slots, "default", {
|
|
270
|
+
placement: H.value,
|
|
271
|
+
tip: I.value ? {
|
|
272
|
+
x: I.value.tx,
|
|
273
|
+
y: I.value.ty
|
|
274
|
+
} : D.value
|
|
275
|
+
}, () => [o[0] ||= r("p", { style: {
|
|
276
|
+
margin: 0,
|
|
277
|
+
fontSize: "14px",
|
|
278
|
+
fontWeight: 600,
|
|
279
|
+
color: "#1e293b"
|
|
280
|
+
} }, "Callout content", -1)])], 16), g.target && V.value ? (l(), n("svg", {
|
|
281
|
+
key: 0,
|
|
282
|
+
class: "vue-pointing-bubble__svg",
|
|
283
|
+
style: a({
|
|
284
|
+
position: "absolute",
|
|
285
|
+
inset: 0,
|
|
286
|
+
width: "100%",
|
|
287
|
+
height: "100%",
|
|
288
|
+
overflow: "visible",
|
|
289
|
+
zIndex: k.value?.zIndex
|
|
290
|
+
})
|
|
291
|
+
}, [r("path", {
|
|
292
|
+
class: "vue-pointing-bubble__path",
|
|
293
|
+
d: V.value.fill,
|
|
294
|
+
fill: k.value.fill
|
|
295
|
+
}, null, 8, m), V.value.stroke ? (l(), n("path", {
|
|
296
|
+
key: 0,
|
|
297
|
+
class: "vue-pointing-bubble__path",
|
|
298
|
+
d: V.value.stroke,
|
|
299
|
+
fill: "none",
|
|
300
|
+
stroke: k.value.stroke,
|
|
301
|
+
"stroke-width": k.value.strokeWidth,
|
|
302
|
+
"stroke-linejoin": "round",
|
|
303
|
+
"stroke-linecap": "butt"
|
|
304
|
+
}, null, 8, h)) : t("", !0)], 4)) : t("", !0)], 12, p));
|
|
305
|
+
}
|
|
306
|
+
}), w = { install(e, t = {}) {
|
|
307
|
+
e.component(t.name || "PointingBubble", C);
|
|
308
|
+
} };
|
|
309
|
+
//#endregion
|
|
310
|
+
export { C as Callout, C as PointingBubble, w as default };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
(function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports,require("vue")):typeof define==`function`&&define.amd?define([`exports`,`vue`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.VuePointingBubble={},e.Vue))})(this,function(e,t){Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});var n=[`data-placement`],r=[`d`,`fill`],i=[`d`,`stroke`,`stroke-width`],a=`:where(.vue-pointing-bubble__inner) {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
width: var(--vpb-width, max-content);
|
|
4
|
+
max-width: var(--vpb-max-width, none);
|
|
5
|
+
padding: var(--vpb-padding);
|
|
6
|
+
background: white;
|
|
7
|
+
border: 2px solid gray;
|
|
8
|
+
border-radius: 8px;
|
|
9
|
+
}`,o=`drop-shadow(0 6px 8px rgba(0, 0, 0, 0.25))`,s=30,c=280,l=[`tx`,`ty`,`bx`,`by`],u=[`tx`,`ty`],d=()=>{if(typeof document>`u`||document.getElementById(`vue-pointing-bubble-styles`))return;let e=document.createElement(`style`);e.id=`vue-pointing-bubble-styles`,e.textContent=a,document.head.prepend(e)},f=Object.assign({inheritAttrs:!1},{__name:`PointingBubble`,props:{target:{type:[Object,String,typeof Element<`u`?Element:Object],default:null},placement:{type:String,default:`auto`,validator:e=>[`auto`,`top-left`,`top-right`,`bottom-left`,`bottom-right`,`manual`].includes(e)},targetAnchor:{type:String,default:`center`,validator:e=>[`center`,`edge`].includes(e)},maxWidth:{type:Number,default:void 0},tailLength:{type:Number,default:45},tailBaseWidth:{type:Number,default:24},padding:{type:[Number,String],default:8},shadow:{type:Boolean,default:!0},transitionDuration:{type:Number,default:200}},setup(e,{expose:a}){let f=e;d();let p=(0,t.ref)(null),m=(0,t.ref)(null),h=(0,t.ref)(null),g=(0,t.ref)(null),_=(0,t.ref)(null),v=(0,t.ref)({left:0,top:0,width:0,height:0,scaleX:1,scaleY:1,viewportWidth:0,viewportHeight:0}),y=(0,t.computed)(()=>f.placement===`manual`),b=(e,t)=>!!e&&!!t&&Object.keys(e).every(n=>e[n]===t[n]),x=e=>typeof Element>`u`||!e?null:typeof e==`string`?document.querySelector(e):e instanceof Element?e:e.$el instanceof Element?e.$el:null,S=()=>{let e=p.value;if(!f.target||!e){h.value=null;return}let t=e.getBoundingClientRect(),n=e.offsetWidth?t.width/e.offsetWidth:1,r=e.offsetHeight?t.height/e.offsetHeight:1;v.value={left:t.left,top:t.top,width:e.offsetWidth,height:e.offsetHeight,scaleX:n,scaleY:r,viewportWidth:window.innerWidth,viewportHeight:window.innerHeight};let i=x(f.target);if(i){let e=i.getBoundingClientRect(),a={left:(e.left-t.left)/n,top:(e.top-t.top)/r,right:(e.right-t.left)/n,bottom:(e.bottom-t.top)/r};b(a,g.value)||(g.value=a),h.value={x:(a.left+a.right)/2,y:(a.top+a.bottom)/2}}else typeof f.target.x==`number`&&typeof f.target.y==`number`?(g.value=null,h.value={x:f.target.x,y:f.target.y}):(g.value=null,h.value=null);C()};(0,t.watch)(()=>[f.target,f.target?.x,f.target?.y],S,{flush:`post`});let C=()=>{let e=m.value;if(!e||!f.target)return;let t=getComputedStyle(e),n={x:y.value?e.offsetLeft:0,y:y.value?e.offsetTop:0,width:e.offsetWidth,height:e.offsetHeight,fill:t.backgroundColor,stroke:t.borderTopColor,strokeWidth:parseFloat(t.borderTopWidth)||0,radius:t.borderTopLeftRadius,zIndex:t.zIndex};b(n,_.value)||(_.value=n)};(0,t.onUpdated)(C);let w=(0,t.computed)(()=>{let e=_.value;if(!h.value||!e?.width||!e?.height)return null;let{width:t,height:n}=e,{x:r,y:i}=h.value,a=f.tailLength,o=v.value,s=f.targetAnchor===`edge`?g.value:null;if(y.value){if(s){let r=e.x+t/2,i=e.y+n/2;if(!(r>=s.left&&r<=s.right&&i>=s.top&&i<=s.bottom))return{placement:`manual`,tx:Math.min(s.right,Math.max(s.left,r)),ty:Math.min(s.bottom,Math.max(s.top,i)),bx:e.x,by:e.y,w:t,h:n}}return{placement:`manual`,tx:r,ty:i,bx:e.x,by:e.y,w:t,h:n}}let c=e=>s?e===`top`?s.top:s.bottom:i,l=f.placement;if(l===`auto`){let e=i>(o.height||600)/2?`top`:`bottom`,s=r>(o.width||800)/2?`left`:`right`,{scaleX:u,scaleY:d}=o;if(o.viewportHeight){let t=o.top+(c(`top`)-n-a)*d>=0,r=o.top+(c(`bottom`)+n+a)*d<=o.viewportHeight;e===`top`&&!t&&r?e=`bottom`:e===`bottom`&&!r&&t&&(e=`top`)}if(o.viewportWidth){let e=o.left+(r-t-a)*u>=0,n=o.left+(r+t+a)*u<=o.viewportWidth;s===`left`&&!e&&n?s=`right`:s===`right`&&!n&&e&&(s=`left`)}l=`${e}-${s}`}let[u,d]=l.split(`-`),p=c(u);return{placement:l,tx:r,ty:p,bx:d===`left`?r-t-a:r+a,by:u===`top`?p-n-a:p+a,w:t,h:n}}),T=(0,t.ref)(null),E=0,D=()=>typeof matchMedia<`u`&&matchMedia(`(prefers-reduced-motion: reduce)`).matches;(0,t.watch)(w,e=>{typeof cancelAnimationFrame<`u`&&cancelAnimationFrame(E);let t=T.value,n=f.transitionDuration;if(!e||!t||n<=0||typeof requestAnimationFrame>`u`||D()){T.value=e;return}let r=performance.now(),i=a=>{let o=Math.min(1,(a-r)/n),s=1-(1-o)**3,c={...e},d=e.placement===`manual`?u:l;for(let n of d)c[n]=t[n]+(e[n]-t[n])*s;T.value=c,o<1&&(E=requestAnimationFrame(i))};E=requestAnimationFrame(i)},{immediate:!0});let O=null;(0,t.onMounted)(()=>{window.addEventListener(`resize`,S),window.addEventListener(`scroll`,S,!0),typeof ResizeObserver<`u`&&(O=new ResizeObserver(S),O.observe(p.value),O.observe(m.value)),S()}),(0,t.onBeforeUnmount)(()=>{window.removeEventListener(`resize`,S),window.removeEventListener(`scroll`,S,!0),O?.disconnect(),typeof cancelAnimationFrame<`u`&&cancelAnimationFrame(E)});let k=(0,t.computed)(()=>{let e=T.value;if(!e)return null;let{placement:t,tx:n,ty:r,bx:i,by:a,w:o,h:s}=e,c=_.value.strokeWidth,l=_.value.radius,u=parseFloat(l)*(l.endsWith(`%`)?Math.min(o,s)/100:1)||0,d=f.tailBaseWidth/2,p=(e,t,n)=>t>n?(t+n)/2:Math.min(n,Math.max(t,e)),m=null,h=0;if(t===`manual`){let e=Math.max(i-n,n-(i+o),0),t=Math.max(a-r,r-(a+s),0);if(e>0&&e>=t)m=n<i?`left`:`right`,h=r;else if(t>0)m=r<a?`top`:`bottom`,h=n;else return null}else{let[e,n]=t.split(`-`);m=n===`left`?`right`:`left`,h=a+s*(e===`top`?.65:.35)}let g=(e,t)=>`${e} ${t}`,v=(e,t)=>`M ${g(...e)} L ${n} ${r} L ${g(...t)}`,y=c/2;if(m===`left`||m===`right`){let e=p(h,a+u+d,a+s-u-d),t=e-d,n=e+d,r=m===`left`?i+c+1:i+o-c-1,l=m===`left`?i+y:i+o-y;return{fill:v([r,t],[r,n]),stroke:c?v([l,t],[l,n]):null}}let b=p(h,i+u+d,i+o-u-d),x=b-d,S=b+d,C=m===`top`?a+c+1:a+s-c-1,w=m===`top`?a+y:a+s-y;return{fill:v([x,C],[S,C]),stroke:c?v([x,w],[S,w]):null}}),A=(0,t.computed)(()=>f.target?T.value?.placement:`none`),j=(0,t.computed)(()=>{if(!f.target)return{};let e=_.value?.zIndex;return{position:`absolute`,inset:0,pointerEvents:`none`,zIndex:e&&e!==`auto`?e:s,filter:f.shadow?o:void 0}}),M=(0,t.computed)(()=>{let e={"--vpb-padding":typeof f.padding==`number`?`${f.padding}px`:f.padding},t=f.maxWidth??(f.target?c:null);if(t!=null&&(e[`--vpb-max-width`]=t+`px`),!f.target)return e[`--vpb-width`]=`fit-content`,f.shadow&&(e.filter=o),e;let n=T.value;return Object.assign(e,{position:`absolute`,pointerEvents:`auto`,visibility:n?`visible`:`hidden`}),y.value||Object.assign(e,{left:(n?.bx??0)+`px`,top:(n?.by??0)+`px`,margin:0}),e});return a({update:S}),(a,o)=>((0,t.openBlock)(),(0,t.createElementBlock)(`div`,{ref_key:`rootRef`,ref:p,class:`vue-pointing-bubble`,"data-placement":A.value,style:(0,t.normalizeStyle)(j.value)},[(0,t.createElementVNode)(`div`,(0,t.mergeProps)({ref_key:`boxRef`,ref:m,class:`vue-pointing-bubble__inner`},a.$attrs,{style:M.value}),[(0,t.renderSlot)(a.$slots,`default`,{placement:A.value,tip:w.value?{x:w.value.tx,y:w.value.ty}:h.value},()=>[o[0]||=(0,t.createElementVNode)(`p`,{style:{margin:0,fontSize:`14px`,fontWeight:600,color:`#1e293b`}},`Callout content`,-1)])],16),e.target&&k.value?((0,t.openBlock)(),(0,t.createElementBlock)(`svg`,{key:0,class:`vue-pointing-bubble__svg`,style:(0,t.normalizeStyle)({position:`absolute`,inset:0,width:`100%`,height:`100%`,overflow:`visible`,zIndex:_.value?.zIndex})},[(0,t.createElementVNode)(`path`,{class:`vue-pointing-bubble__path`,d:k.value.fill,fill:_.value.fill},null,8,r),k.value.stroke?((0,t.openBlock)(),(0,t.createElementBlock)(`path`,{key:0,class:`vue-pointing-bubble__path`,d:k.value.stroke,fill:`none`,stroke:_.value.stroke,"stroke-width":_.value.strokeWidth,"stroke-linejoin":`round`,"stroke-linecap":`butt`},null,8,i)):(0,t.createCommentVNode)(``,!0)],4)):(0,t.createCommentVNode)(``,!0)],12,n))}});e.Callout=f,e.PointingBubble=f,e.default={install(e,t={}){e.component(t.name||`PointingBubble`,f)}}});
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vue-pointing-bubble",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vue 3 callout / speech bubble component whose tail points exactly at absolute (x, y) coordinates or at a DOM element.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vue",
|
|
7
|
+
"vue3",
|
|
8
|
+
"callout",
|
|
9
|
+
"speech-bubble",
|
|
10
|
+
"tooltip",
|
|
11
|
+
"popover",
|
|
12
|
+
"pointer",
|
|
13
|
+
"svg"
|
|
14
|
+
],
|
|
15
|
+
"author": "Alexey Murz Korepov",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"homepage": "https://murznn.github.io/vue-pointing-bubble/",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/MurzNN/vue-pointing-bubble.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/MurzNN/vue-pointing-bubble/issues"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./dist/vue-pointing-bubble.umd.cjs",
|
|
27
|
+
"module": "./dist/vue-pointing-bubble.js",
|
|
28
|
+
"unpkg": "./dist/vue-pointing-bubble.umd.cjs",
|
|
29
|
+
"jsdelivr": "./dist/vue-pointing-bubble.umd.cjs",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/vue-pointing-bubble.js",
|
|
35
|
+
"require": "./dist/vue-pointing-bubble.umd.cjs"
|
|
36
|
+
},
|
|
37
|
+
"./PointingBubble.vue": "./registry/ui/pointing-bubble/PointingBubble.vue"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"src",
|
|
42
|
+
"registry"
|
|
43
|
+
],
|
|
44
|
+
"sideEffects": false,
|
|
45
|
+
"scripts": {
|
|
46
|
+
"dev": "npm run dev:demo",
|
|
47
|
+
"dev:demo": "vite --config vite.demo.config.js",
|
|
48
|
+
"build": "vite build && cp src/index.d.ts dist/index.d.ts",
|
|
49
|
+
"build:demo": "npm run registry:build && vite build --config vite.demo.config.js",
|
|
50
|
+
"registry:build": "shadcn-vue build --output demo/public/r",
|
|
51
|
+
"preview:demo": "vite preview --config vite.demo.config.js",
|
|
52
|
+
"test": "playwright test",
|
|
53
|
+
"prepare": "npm run build"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"vue": "^3.3.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@playwright/test": "^1.63.0",
|
|
60
|
+
"@vitejs/plugin-vue": "^6.0.9",
|
|
61
|
+
"shadcn-vue": "^2.8.2",
|
|
62
|
+
"vite": "^8.3.2",
|
|
63
|
+
"vue": "^3.5.43"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
// Default look of the box. `:where()` has zero specificity, so any class or style set on the component wins.
|
|
3
|
+
const CSS = `:where(.vue-pointing-bubble__inner) {
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
width: var(--vpb-width, max-content);
|
|
6
|
+
max-width: var(--vpb-max-width, none);
|
|
7
|
+
padding: var(--vpb-padding);
|
|
8
|
+
background: white;
|
|
9
|
+
border: 2px solid gray;
|
|
10
|
+
border-radius: 8px;
|
|
11
|
+
}`
|
|
12
|
+
const SHADOW = 'drop-shadow(0 6px 8px rgba(0, 0, 0, 0.25))'
|
|
13
|
+
const DEFAULT_Z_INDEX = 30
|
|
14
|
+
const DEFAULT_MAX_WIDTH = 280
|
|
15
|
+
// In manual mode CSS owns the box, so only the tail glides.
|
|
16
|
+
const ANIMATED_KEYS = ['tx', 'ty', 'bx', 'by']
|
|
17
|
+
const MANUAL_ANIMATED_KEYS = ['tx', 'ty']
|
|
18
|
+
|
|
19
|
+
const injectStyles = () => {
|
|
20
|
+
if (typeof document === 'undefined' || document.getElementById('vue-pointing-bubble-styles')) return
|
|
21
|
+
const style = document.createElement('style')
|
|
22
|
+
style.id = 'vue-pointing-bubble-styles'
|
|
23
|
+
style.textContent = CSS
|
|
24
|
+
document.head.prepend(style)
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<script setup>
|
|
29
|
+
/**
|
|
30
|
+
* Precision Vue 3 callout ("pointing bubble") component.
|
|
31
|
+
* The box is a regular element styled with CSS; an SVG tail is drawn from its wall,
|
|
32
|
+
* so the tip lands exactly on the target coordinate (x, y) or on a target element.
|
|
33
|
+
* Without a target, only the box is rendered, in the normal document flow.
|
|
34
|
+
*/
|
|
35
|
+
import { ref, computed, watch, onMounted, onUpdated, onBeforeUnmount } from 'vue'
|
|
36
|
+
|
|
37
|
+
// `class`, `style`, and listeners go to the box, not to the full-size overlay.
|
|
38
|
+
defineOptions({ inheritAttrs: false })
|
|
39
|
+
|
|
40
|
+
const props = defineProps({
|
|
41
|
+
// DOM element, component instance, CSS selector, or { x, y } relative to the positioned parent.
|
|
42
|
+
target: {
|
|
43
|
+
type: [Object, String, typeof Element !== 'undefined' ? Element : Object],
|
|
44
|
+
default: null
|
|
45
|
+
},
|
|
46
|
+
// 'auto' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'manual'
|
|
47
|
+
// In 'manual' mode the box is positioned by CSS (left/top) on the component.
|
|
48
|
+
placement: {
|
|
49
|
+
type: String,
|
|
50
|
+
default: 'auto',
|
|
51
|
+
validator: (v) => ['auto', 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'manual'].includes(v)
|
|
52
|
+
},
|
|
53
|
+
// For element targets: 'center' points at the element's center, 'edge' at its border facing the box.
|
|
54
|
+
targetAnchor: {
|
|
55
|
+
type: String,
|
|
56
|
+
default: 'center',
|
|
57
|
+
validator: (v) => ['center', 'edge'].includes(v)
|
|
58
|
+
},
|
|
59
|
+
// Content wraps at this width (px) unless CSS on the component sets a width or max-width.
|
|
60
|
+
// Defaults to 280 with a target; without one, the box is only limited by its parent.
|
|
61
|
+
maxWidth: { type: Number, default: undefined },
|
|
62
|
+
// Distance from the box edge to the tip.
|
|
63
|
+
tailLength: { type: Number, default: 45 },
|
|
64
|
+
// Width of the tail where it attaches to the box.
|
|
65
|
+
tailBaseWidth: { type: Number, default: 24 },
|
|
66
|
+
// Used unless CSS on the component sets a padding.
|
|
67
|
+
padding: { type: [Number, String], default: 8 },
|
|
68
|
+
shadow: { type: Boolean, default: true },
|
|
69
|
+
// Duration in ms of the move animation when the target changes; 0 disables it.
|
|
70
|
+
transitionDuration: { type: Number, default: 200 }
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
injectStyles()
|
|
74
|
+
|
|
75
|
+
const rootRef = ref(null)
|
|
76
|
+
const boxRef = ref(null)
|
|
77
|
+
const tip = ref(null)
|
|
78
|
+
// Target element rect relative to the root; null for coordinate targets.
|
|
79
|
+
const targetRect = ref(null)
|
|
80
|
+
// Size and look of the box, read from the rendered element.
|
|
81
|
+
const box = ref(null)
|
|
82
|
+
// Viewport origin, local size, cumulative scale, and the viewport size.
|
|
83
|
+
const frameRect = ref({ left: 0, top: 0, width: 0, height: 0, scaleX: 1, scaleY: 1, viewportWidth: 0, viewportHeight: 0 })
|
|
84
|
+
const manual = computed(() => props.placement === 'manual')
|
|
85
|
+
|
|
86
|
+
const sameKeys = (a, b) => !!a && !!b && Object.keys(a).every((k) => a[k] === b[k])
|
|
87
|
+
|
|
88
|
+
const resolveElement = (t) => {
|
|
89
|
+
if (typeof Element === 'undefined' || !t) return null
|
|
90
|
+
// Looked up on every update, so an element that appears later is found too.
|
|
91
|
+
if (typeof t === 'string') return document.querySelector(t)
|
|
92
|
+
if (t instanceof Element) return t
|
|
93
|
+
if (t.$el instanceof Element) return t.$el
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const update = () => {
|
|
98
|
+
const root = rootRef.value
|
|
99
|
+
if (!props.target || !root) {
|
|
100
|
+
tip.value = null
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rootRect = root.getBoundingClientRect()
|
|
105
|
+
// getBoundingClientRect is in viewport pixels, after ancestor transforms such as
|
|
106
|
+
// Slidev's scale(). left/top and the SVG path are in the root's local CSS pixels.
|
|
107
|
+
const scaleX = root.offsetWidth ? rootRect.width / root.offsetWidth : 1
|
|
108
|
+
const scaleY = root.offsetHeight ? rootRect.height / root.offsetHeight : 1
|
|
109
|
+
frameRect.value = {
|
|
110
|
+
left: rootRect.left,
|
|
111
|
+
top: rootRect.top,
|
|
112
|
+
width: root.offsetWidth,
|
|
113
|
+
height: root.offsetHeight,
|
|
114
|
+
scaleX,
|
|
115
|
+
scaleY,
|
|
116
|
+
viewportWidth: window.innerWidth,
|
|
117
|
+
viewportHeight: window.innerHeight
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const el = resolveElement(props.target)
|
|
121
|
+
if (el) {
|
|
122
|
+
const r = el.getBoundingClientRect()
|
|
123
|
+
const rect = {
|
|
124
|
+
left: (r.left - rootRect.left) / scaleX,
|
|
125
|
+
top: (r.top - rootRect.top) / scaleY,
|
|
126
|
+
right: (r.right - rootRect.left) / scaleX,
|
|
127
|
+
bottom: (r.bottom - rootRect.top) / scaleY
|
|
128
|
+
}
|
|
129
|
+
if (!sameKeys(rect, targetRect.value)) targetRect.value = rect
|
|
130
|
+
tip.value = { x: (rect.left + rect.right) / 2, y: (rect.top + rect.bottom) / 2 }
|
|
131
|
+
} else if (typeof props.target.x === 'number' && typeof props.target.y === 'number') {
|
|
132
|
+
targetRect.value = null
|
|
133
|
+
tip.value = { x: props.target.x, y: props.target.y }
|
|
134
|
+
} else {
|
|
135
|
+
// A selector that matches nothing (yet): the bubble stays hidden.
|
|
136
|
+
targetRect.value = null
|
|
137
|
+
tip.value = null
|
|
138
|
+
}
|
|
139
|
+
measure()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
watch(() => [props.target, props.target?.x, props.target?.y], update, { flush: 'post' })
|
|
143
|
+
|
|
144
|
+
const measure = () => {
|
|
145
|
+
const el = boxRef.value
|
|
146
|
+
if (!el || !props.target) return
|
|
147
|
+
const s = getComputedStyle(el)
|
|
148
|
+
const next = {
|
|
149
|
+
// The position only matters in manual mode, where CSS places the box.
|
|
150
|
+
x: manual.value ? el.offsetLeft : 0,
|
|
151
|
+
y: manual.value ? el.offsetTop : 0,
|
|
152
|
+
width: el.offsetWidth,
|
|
153
|
+
height: el.offsetHeight,
|
|
154
|
+
fill: s.backgroundColor,
|
|
155
|
+
stroke: s.borderTopColor,
|
|
156
|
+
strokeWidth: parseFloat(s.borderTopWidth) || 0,
|
|
157
|
+
radius: s.borderTopLeftRadius,
|
|
158
|
+
zIndex: s.zIndex
|
|
159
|
+
}
|
|
160
|
+
if (!sameKeys(next, box.value)) box.value = next
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Catches changes of the component's class/style, which are not reactive in the script.
|
|
164
|
+
onUpdated(measure)
|
|
165
|
+
|
|
166
|
+
// Where the bubble should end up for the current tip, box size, and props.
|
|
167
|
+
const geometry = computed(() => {
|
|
168
|
+
const m = box.value
|
|
169
|
+
if (!tip.value || !m?.width || !m?.height) return null
|
|
170
|
+
const { width: w, height: h } = m
|
|
171
|
+
const { x: tx, y: ty } = tip.value
|
|
172
|
+
const tLen = props.tailLength
|
|
173
|
+
const f = frameRect.value
|
|
174
|
+
const rect = props.targetAnchor === 'edge' ? targetRect.value : null
|
|
175
|
+
|
|
176
|
+
if (manual.value) {
|
|
177
|
+
if (rect) {
|
|
178
|
+
// The point of the element's border nearest to the box center, unless that center is over the element.
|
|
179
|
+
const cx = m.x + w / 2
|
|
180
|
+
const cy = m.y + h / 2
|
|
181
|
+
const inside = cx >= rect.left && cx <= rect.right && cy >= rect.top && cy <= rect.bottom
|
|
182
|
+
if (!inside) {
|
|
183
|
+
const ex = Math.min(rect.right, Math.max(rect.left, cx))
|
|
184
|
+
const ey = Math.min(rect.bottom, Math.max(rect.top, cy))
|
|
185
|
+
return { placement: 'manual', tx: ex, ty: ey, bx: m.x, by: m.y, w, h }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { placement: 'manual', tx, ty, bx: m.x, by: m.y, w, h }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// With the edge anchor the tip moves to the middle of the element's top or bottom edge, facing the box.
|
|
192
|
+
const tipYFor = (vertical) => (rect ? (vertical === 'top' ? rect.top : rect.bottom) : ty)
|
|
193
|
+
|
|
194
|
+
let placement = props.placement
|
|
195
|
+
if (placement === 'auto') {
|
|
196
|
+
let vertical = ty > (f.height || 600) / 2 ? 'top' : 'bottom'
|
|
197
|
+
let horizontal = tx > (f.width || 800) / 2 ? 'left' : 'right'
|
|
198
|
+
|
|
199
|
+
// Flip to the other side if the bubble would leave the viewport and fits there.
|
|
200
|
+
// Tip, box, and tail are local pixels; multiply by the scale to reach the viewport.
|
|
201
|
+
const { scaleX: sx, scaleY: sy } = f
|
|
202
|
+
if (f.viewportHeight) {
|
|
203
|
+
const fitsAbove = f.top + (tipYFor('top') - h - tLen) * sy >= 0
|
|
204
|
+
const fitsBelow = f.top + (tipYFor('bottom') + h + tLen) * sy <= f.viewportHeight
|
|
205
|
+
if (vertical === 'top' && !fitsAbove && fitsBelow) vertical = 'bottom'
|
|
206
|
+
else if (vertical === 'bottom' && !fitsBelow && fitsAbove) vertical = 'top'
|
|
207
|
+
}
|
|
208
|
+
if (f.viewportWidth) {
|
|
209
|
+
const fitsLeft = f.left + (tx - w - tLen) * sx >= 0
|
|
210
|
+
const fitsRight = f.left + (tx + w + tLen) * sx <= f.viewportWidth
|
|
211
|
+
if (horizontal === 'left' && !fitsLeft && fitsRight) horizontal = 'right'
|
|
212
|
+
else if (horizontal === 'right' && !fitsRight && fitsLeft) horizontal = 'left'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
placement = `${vertical}-${horizontal}`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const [vertical, horizontal] = placement.split('-')
|
|
219
|
+
const tipY = tipYFor(vertical)
|
|
220
|
+
return {
|
|
221
|
+
placement,
|
|
222
|
+
tx,
|
|
223
|
+
ty: tipY,
|
|
224
|
+
bx: horizontal === 'left' ? tx - w - tLen : tx + tLen,
|
|
225
|
+
by: vertical === 'top' ? tipY - h - tLen : tipY + tLen,
|
|
226
|
+
w,
|
|
227
|
+
h
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// What is rendered right now; eases towards `geometry` so the tail and the box move together.
|
|
232
|
+
const current = ref(null)
|
|
233
|
+
let frame = 0
|
|
234
|
+
|
|
235
|
+
const prefersReducedMotion = () =>
|
|
236
|
+
typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
237
|
+
|
|
238
|
+
watch(geometry, (to) => {
|
|
239
|
+
if (typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(frame)
|
|
240
|
+
const from = current.value
|
|
241
|
+
const duration = props.transitionDuration
|
|
242
|
+
if (!to || !from || duration <= 0 || typeof requestAnimationFrame === 'undefined' || prefersReducedMotion()) {
|
|
243
|
+
current.value = to
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const start = performance.now()
|
|
248
|
+
const step = (now) => {
|
|
249
|
+
const t = Math.min(1, (now - start) / duration)
|
|
250
|
+
const eased = 1 - Math.pow(1 - t, 3)
|
|
251
|
+
const next = { ...to }
|
|
252
|
+
const keys = to.placement === 'manual' ? MANUAL_ANIMATED_KEYS : ANIMATED_KEYS
|
|
253
|
+
for (const key of keys) next[key] = from[key] + (to[key] - from[key]) * eased
|
|
254
|
+
current.value = next
|
|
255
|
+
if (t < 1) frame = requestAnimationFrame(step)
|
|
256
|
+
}
|
|
257
|
+
frame = requestAnimationFrame(step)
|
|
258
|
+
}, { immediate: true })
|
|
259
|
+
|
|
260
|
+
let resizeObserver = null
|
|
261
|
+
|
|
262
|
+
onMounted(() => {
|
|
263
|
+
window.addEventListener('resize', update)
|
|
264
|
+
window.addEventListener('scroll', update, true)
|
|
265
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
266
|
+
resizeObserver = new ResizeObserver(update)
|
|
267
|
+
resizeObserver.observe(rootRef.value)
|
|
268
|
+
resizeObserver.observe(boxRef.value)
|
|
269
|
+
}
|
|
270
|
+
update()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
onBeforeUnmount(() => {
|
|
274
|
+
window.removeEventListener('resize', update)
|
|
275
|
+
window.removeEventListener('scroll', update, true)
|
|
276
|
+
resizeObserver?.disconnect()
|
|
277
|
+
if (typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(frame)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// The tail: a triangle from the box wall facing the tip.
|
|
281
|
+
// The fill runs a pixel past the inner edge of the border, so it covers the border where the
|
|
282
|
+
// tail attaches even when page zoom snaps that edge to a different pixel.
|
|
283
|
+
// An SVG stroke is centered on its path, so the outline is drawn along the middle of the border;
|
|
284
|
+
// if it followed the fill, half of the border width would land inside the bubble.
|
|
285
|
+
const tail = computed(() => {
|
|
286
|
+
const c = current.value
|
|
287
|
+
if (!c) return null
|
|
288
|
+
const { placement, tx, ty, bx, by, w, h } = c
|
|
289
|
+
const bw = box.value.strokeWidth
|
|
290
|
+
const radius = box.value.radius
|
|
291
|
+
const r = parseFloat(radius) * (radius.endsWith('%') ? Math.min(w, h) / 100 : 1) || 0
|
|
292
|
+
const half = props.tailBaseWidth / 2
|
|
293
|
+
const clamp = (v, lo, hi) => (lo > hi ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, v)))
|
|
294
|
+
|
|
295
|
+
let side = null
|
|
296
|
+
let along = 0
|
|
297
|
+
if (placement === 'manual') {
|
|
298
|
+
const dx = Math.max(bx - tx, tx - (bx + w), 0)
|
|
299
|
+
const dy = Math.max(by - ty, ty - (by + h), 0)
|
|
300
|
+
if (dx > 0 && dx >= dy) {
|
|
301
|
+
side = tx < bx ? 'left' : 'right'
|
|
302
|
+
along = ty
|
|
303
|
+
} else if (dy > 0) {
|
|
304
|
+
side = ty < by ? 'top' : 'bottom'
|
|
305
|
+
along = tx
|
|
306
|
+
} else {
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
const [vertical, horizontal] = placement.split('-')
|
|
311
|
+
side = horizontal === 'left' ? 'right' : 'left'
|
|
312
|
+
along = by + h * (vertical === 'top' ? 0.65 : 0.35)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const point = (x, y) => `${x} ${y}`
|
|
316
|
+
const triangle = (base1, base2) => `M ${point(...base1)} L ${tx} ${ty} L ${point(...base2)}`
|
|
317
|
+
// Center of the border. A stroke of width bw centered here stays within the border.
|
|
318
|
+
const mid = bw / 2
|
|
319
|
+
// One pixel past the inner edge. The fill matches the background, so this overlap is
|
|
320
|
+
// invisible, but it covers the border when page zoom snaps that edge off the path.
|
|
321
|
+
const cover = 1
|
|
322
|
+
|
|
323
|
+
if (side === 'left' || side === 'right') {
|
|
324
|
+
const a = clamp(along, by + r + half, by + h - r - half)
|
|
325
|
+
const y1 = a - half
|
|
326
|
+
const y2 = a + half
|
|
327
|
+
const fillX = side === 'left' ? bx + bw + cover : bx + w - bw - cover
|
|
328
|
+
const strokeX = side === 'left' ? bx + mid : bx + w - mid
|
|
329
|
+
return {
|
|
330
|
+
fill: triangle([fillX, y1], [fillX, y2]),
|
|
331
|
+
stroke: bw ? triangle([strokeX, y1], [strokeX, y2]) : null
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const a = clamp(along, bx + r + half, bx + w - r - half)
|
|
335
|
+
const x1 = a - half
|
|
336
|
+
const x2 = a + half
|
|
337
|
+
const fillY = side === 'top' ? by + bw + cover : by + h - bw - cover
|
|
338
|
+
const strokeY = side === 'top' ? by + mid : by + h - mid
|
|
339
|
+
return {
|
|
340
|
+
fill: triangle([x1, fillY], [x2, fillY]),
|
|
341
|
+
stroke: bw ? triangle([x1, strokeY], [x2, strokeY]) : null
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
const placement = computed(() => (props.target ? current.value?.placement : 'none'))
|
|
346
|
+
|
|
347
|
+
// Without a target the root is a plain block around the box, so opacity or transforms set on it apply to the box too.
|
|
348
|
+
const rootStyle = computed(() => {
|
|
349
|
+
if (!props.target) return {}
|
|
350
|
+
const z = box.value?.zIndex
|
|
351
|
+
return {
|
|
352
|
+
position: 'absolute',
|
|
353
|
+
inset: 0,
|
|
354
|
+
pointerEvents: 'none',
|
|
355
|
+
zIndex: z && z !== 'auto' ? z : DEFAULT_Z_INDEX,
|
|
356
|
+
filter: props.shadow ? SHADOW : undefined
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
// Keys are only added when set: merged with the user's `style`, even an undefined key would win.
|
|
361
|
+
const boxStyle = computed(() => {
|
|
362
|
+
const style = {
|
|
363
|
+
'--vpb-padding': typeof props.padding === 'number' ? `${props.padding}px` : props.padding
|
|
364
|
+
}
|
|
365
|
+
const maxWidth = props.maxWidth ?? (props.target ? DEFAULT_MAX_WIDTH : null)
|
|
366
|
+
if (maxWidth != null) style['--vpb-max-width'] = maxWidth + 'px'
|
|
367
|
+
if (!props.target) {
|
|
368
|
+
// In the flow the box shrinks to its content but never overflows its parent.
|
|
369
|
+
style['--vpb-width'] = 'fit-content'
|
|
370
|
+
if (props.shadow) style.filter = SHADOW
|
|
371
|
+
return style
|
|
372
|
+
}
|
|
373
|
+
const c = current.value
|
|
374
|
+
Object.assign(style, { position: 'absolute', pointerEvents: 'auto', visibility: c ? 'visible' : 'hidden' })
|
|
375
|
+
// In manual mode the box is positioned by its own CSS; otherwise next to the tip, ignoring margins.
|
|
376
|
+
if (!manual.value) Object.assign(style, { left: (c?.bx ?? 0) + 'px', top: (c?.by ?? 0) + 'px', margin: 0 })
|
|
377
|
+
return style
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
defineExpose({ update })
|
|
381
|
+
</script>
|
|
382
|
+
|
|
383
|
+
<template>
|
|
384
|
+
<div ref="rootRef" class="vue-pointing-bubble" :data-placement="placement" :style="rootStyle">
|
|
385
|
+
<div ref="boxRef" class="vue-pointing-bubble__inner" v-bind="$attrs" :style="boxStyle">
|
|
386
|
+
<slot :placement="placement" :tip="geometry ? { x: geometry.tx, y: geometry.ty } : tip">
|
|
387
|
+
<p :style="{ margin: 0, fontSize: '14px', fontWeight: 600, color: '#1e293b' }">Callout content</p>
|
|
388
|
+
</slot>
|
|
389
|
+
</div>
|
|
390
|
+
<svg
|
|
391
|
+
v-if="target && tail"
|
|
392
|
+
class="vue-pointing-bubble__svg"
|
|
393
|
+
:style="{ position: 'absolute', inset: 0, width: '100%', height: '100%', overflow: 'visible', zIndex: box?.zIndex }"
|
|
394
|
+
>
|
|
395
|
+
<path class="vue-pointing-bubble__path" :d="tail.fill" :fill="box.fill" />
|
|
396
|
+
<path
|
|
397
|
+
v-if="tail.stroke"
|
|
398
|
+
class="vue-pointing-bubble__path"
|
|
399
|
+
:d="tail.stroke"
|
|
400
|
+
fill="none"
|
|
401
|
+
:stroke="box.stroke"
|
|
402
|
+
:stroke-width="box.strokeWidth"
|
|
403
|
+
stroke-linejoin="round"
|
|
404
|
+
stroke-linecap="butt"
|
|
405
|
+
/>
|
|
406
|
+
</svg>
|
|
407
|
+
</div>
|
|
408
|
+
</template>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as PointingBubble } from "./PointingBubble.vue"
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { DefineComponent, Plugin, ComponentPublicInstance } from 'vue'
|
|
2
|
+
|
|
3
|
+
export type PointingBubblePlacement =
|
|
4
|
+
| 'auto'
|
|
5
|
+
| 'top-left'
|
|
6
|
+
| 'top-right'
|
|
7
|
+
| 'bottom-left'
|
|
8
|
+
| 'bottom-right'
|
|
9
|
+
| 'manual'
|
|
10
|
+
|
|
11
|
+
export interface PointingBubblePoint {
|
|
12
|
+
x: number
|
|
13
|
+
y: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PointingBubbleTarget =
|
|
17
|
+
| PointingBubblePoint
|
|
18
|
+
| Element
|
|
19
|
+
| ComponentPublicInstance
|
|
20
|
+
/** A CSS selector, looked up with `document.querySelector`. */
|
|
21
|
+
| string
|
|
22
|
+
| null
|
|
23
|
+
|
|
24
|
+
export interface PointingBubbleProps {
|
|
25
|
+
target?: PointingBubbleTarget
|
|
26
|
+
placement?: PointingBubblePlacement
|
|
27
|
+
targetAnchor?: 'center' | 'edge'
|
|
28
|
+
maxWidth?: number
|
|
29
|
+
tailLength?: number
|
|
30
|
+
tailBaseWidth?: number
|
|
31
|
+
padding?: number | string
|
|
32
|
+
shadow?: boolean
|
|
33
|
+
transitionDuration?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export declare const PointingBubble: DefineComponent<PointingBubbleProps>
|
|
37
|
+
export declare const Callout: typeof PointingBubble
|
|
38
|
+
|
|
39
|
+
export interface PointingBubblePluginOptions {
|
|
40
|
+
name?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
declare const plugin: Plugin<[PointingBubblePluginOptions?]>
|
|
44
|
+
export default plugin
|
package/src/index.js
ADDED