@vue-interface/tooltip 2.0.4 → 2.0.5
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/package.json +2 -1
- package/src/Tooltip.vue +174 -0
- package/src/TooltipPlugin.ts +137 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vue-interface/tooltip",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.5",
|
|
4
4
|
"description": "A Vue tooltip component.",
|
|
5
5
|
"readme": "README.md",
|
|
6
6
|
"type": "module",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"@floating-ui/dom": "^1.7.2"
|
|
46
46
|
},
|
|
47
47
|
"files": [
|
|
48
|
+
"src",
|
|
48
49
|
"dist",
|
|
49
50
|
"index.css",
|
|
50
51
|
"README.md",
|
package/src/Tooltip.vue
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { arrow, autoUpdate, flip as flipFn, FlipOptions, MaybeElement, offset as offsetFn, OffsetOptions, ReferenceElement, useFloating, UseFloatingOptions } from '@floating-ui/vue';
|
|
3
|
+
import { computed, isRef, onUnmounted, Ref, ref, shallowReadonly, ShallowRef, useTemplateRef, watchEffect } from 'vue';
|
|
4
|
+
|
|
5
|
+
export type TooltipProps = {
|
|
6
|
+
title?: string;
|
|
7
|
+
show?: boolean;
|
|
8
|
+
target?: Ref<MaybeElement<ReferenceElement>> | ReferenceElement;
|
|
9
|
+
placement?: UseFloatingOptions['placement'];
|
|
10
|
+
strategy?: UseFloatingOptions['strategy'];
|
|
11
|
+
middleware?: (arrow: Readonly<ShallowRef<HTMLDivElement | null>>) => UseFloatingOptions['middleware'];
|
|
12
|
+
flip?: FlipOptions;
|
|
13
|
+
offset?: OffsetOptions;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(defineProps<TooltipProps>(), {
|
|
17
|
+
title: undefined,
|
|
18
|
+
target: undefined,
|
|
19
|
+
placement: 'top',
|
|
20
|
+
middleware: undefined,
|
|
21
|
+
strategy: undefined,
|
|
22
|
+
flip: undefined,
|
|
23
|
+
offset: undefined,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
defineSlots<{
|
|
27
|
+
default: () => void
|
|
28
|
+
}>();
|
|
29
|
+
|
|
30
|
+
const tooltipEl = useTemplateRef<HTMLDivElement>('tooltipEl');
|
|
31
|
+
const arrowEl = useTemplateRef<HTMLDivElement>('arrowEl');
|
|
32
|
+
const isShowing = ref(false);
|
|
33
|
+
const hash = Math.random().toString(36).slice(2, 12);
|
|
34
|
+
|
|
35
|
+
const targetEl = isRef(props.target)
|
|
36
|
+
? props.target
|
|
37
|
+
: shallowReadonly(ref(props.target));
|
|
38
|
+
|
|
39
|
+
const id = computed(() => {
|
|
40
|
+
if(!(targetEl.value instanceof Element)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return targetEl.value.getAttribute('data-tooltip-id');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
watchEffect(() => {
|
|
49
|
+
if(!targetEl.value || id.value) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if(targetEl.value instanceof Element) {
|
|
54
|
+
targetEl.value.setAttribute('data-tooltip-id', hash);
|
|
55
|
+
targetEl.value.addEventListener('mouseover', open);
|
|
56
|
+
targetEl.value.addEventListener('mouseout', close);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
watchEffect(() => {
|
|
61
|
+
isShowing.value = props.show;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const dynamicOffset = computed<OffsetOptions>(() => {
|
|
65
|
+
if(props.offset) {
|
|
66
|
+
return props.offset;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
const { height } = arrowEl.value ? getComputedStyle(arrowEl.value) : { height: '0px' };
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
mainAxis: parseInt(height.replace('px', '')),
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const { floatingStyles, middlewareData, placement } = useFloating(targetEl, tooltipEl, {
|
|
79
|
+
placement: props.placement,
|
|
80
|
+
middleware: props.middleware?.(arrowEl) ?? [
|
|
81
|
+
flipFn(props.flip),
|
|
82
|
+
offsetFn(dynamicOffset.value),
|
|
83
|
+
arrow({
|
|
84
|
+
element: arrowEl
|
|
85
|
+
}),
|
|
86
|
+
],
|
|
87
|
+
whileElementsMounted: autoUpdate
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const tooltipClasses = computed(() => {
|
|
91
|
+
return {
|
|
92
|
+
show: isShowing.value
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
type Side = 'bottom' | 'left' | 'top' | 'right';
|
|
97
|
+
|
|
98
|
+
const side = computed(() => placement.value.split('-')[0] as Side);
|
|
99
|
+
|
|
100
|
+
const arrowPosition = computed(() => {
|
|
101
|
+
return {
|
|
102
|
+
top: 'bottom',
|
|
103
|
+
right: 'left',
|
|
104
|
+
bottom: 'top',
|
|
105
|
+
left: 'right'
|
|
106
|
+
}[side.value] as Side;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const arrowRotation = computed<Record<Side,string>>(() => ({
|
|
110
|
+
top: 'rotate(225deg)',
|
|
111
|
+
right: 'rotate(-45deg)',
|
|
112
|
+
bottom: 'rotate(45deg)',
|
|
113
|
+
left: 'rotate(135deg)',
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
function open() {
|
|
117
|
+
isShowing.value = true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function close() {
|
|
121
|
+
isShowing.value = false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onUnmounted(() => {
|
|
125
|
+
if(targetEl.value instanceof Element) {
|
|
126
|
+
targetEl.value.removeAttribute('data-tooltip-id');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
defineExpose({
|
|
131
|
+
open,
|
|
132
|
+
close,
|
|
133
|
+
tooltipEl,
|
|
134
|
+
arrowEl,
|
|
135
|
+
isShowing,
|
|
136
|
+
hash,
|
|
137
|
+
});
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<template>
|
|
141
|
+
<Teleport to="body">
|
|
142
|
+
<div
|
|
143
|
+
ref="tooltipEl"
|
|
144
|
+
class="tooltip"
|
|
145
|
+
role="tooltip"
|
|
146
|
+
:data-tooltip-id="hash"
|
|
147
|
+
:class="tooltipClasses"
|
|
148
|
+
:style="floatingStyles">
|
|
149
|
+
<div
|
|
150
|
+
ref="arrowEl"
|
|
151
|
+
class="tooltip-arrow"
|
|
152
|
+
:style="{
|
|
153
|
+
transform: arrowRotation[arrowPosition],
|
|
154
|
+
...Object.assign({
|
|
155
|
+
left:
|
|
156
|
+
middlewareData.arrow?.x != null
|
|
157
|
+
? `${middlewareData.arrow.x}px`
|
|
158
|
+
: '',
|
|
159
|
+
top:
|
|
160
|
+
middlewareData.arrow?.y != null
|
|
161
|
+
? `${middlewareData.arrow.y}px`
|
|
162
|
+
: ''
|
|
163
|
+
}, {
|
|
164
|
+
[arrowPosition]: `calc(${-(arrowEl?.offsetWidth ?? 0) / 2}px)`
|
|
165
|
+
})
|
|
166
|
+
}" />
|
|
167
|
+
<div
|
|
168
|
+
ref="inner"
|
|
169
|
+
class="tooltip-inner">
|
|
170
|
+
<slot>{{ title }}</slot>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</Teleport>
|
|
174
|
+
</template>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { h, render, type App, type Directive } from 'vue';
|
|
2
|
+
import Tooltip, { type TooltipProps } from './Tooltip.vue';
|
|
3
|
+
|
|
4
|
+
const prefix = 'data-tooltip';
|
|
5
|
+
const prefixRegExp = new RegExp(`^${prefix}\-`);
|
|
6
|
+
|
|
7
|
+
function getAttributes(el: Element): Record<string,string> {
|
|
8
|
+
return Array.from(el.attributes)
|
|
9
|
+
.map(a => [a.name, a.value])
|
|
10
|
+
.filter(([key]) => key === 'title' || key.match(prefixRegExp))
|
|
11
|
+
.map(([key, value]) => [key.replace(new RegExp(prefixRegExp), ''), value])
|
|
12
|
+
.reduce((carry, attr) => Object.assign(carry, { [attr[0]]: attr[1] }), {});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createTooltip(target: Element, props?: TooltipProps) {
|
|
16
|
+
const container = document.createElement('template');
|
|
17
|
+
|
|
18
|
+
const vnode = h(Tooltip, Object.assign({
|
|
19
|
+
target
|
|
20
|
+
}, getAttributes(target), props));
|
|
21
|
+
|
|
22
|
+
render(vnode, container);
|
|
23
|
+
|
|
24
|
+
const title = target.getAttribute('title');
|
|
25
|
+
|
|
26
|
+
if(title) {
|
|
27
|
+
target.setAttribute(`${prefix}-og-title`, title);
|
|
28
|
+
target.removeAttribute('title');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return () => {
|
|
32
|
+
if(vnode.component) {
|
|
33
|
+
vnode.component.exposed?.tooltipEl.value?.remove();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function destroyTooltip(target: Element) {
|
|
39
|
+
const tooltips = document.querySelectorAll(
|
|
40
|
+
`[${prefix}-id="${target.getAttribute(`${prefix}-id`)}"]`
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
for(const tooltip of tooltips) {
|
|
44
|
+
tooltip.remove();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
target.removeAttribute(`${prefix}-id`);
|
|
48
|
+
|
|
49
|
+
const title = target.getAttribute(`${prefix}-og-title`);
|
|
50
|
+
|
|
51
|
+
if(title) {
|
|
52
|
+
target.setAttribute('title', title);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function shouldCreateTooltip(target: Node): target is Element {
|
|
57
|
+
if(!(target instanceof Element)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const attrs = getAttributes(target);
|
|
62
|
+
|
|
63
|
+
return !attrs.id && !!attrs.title;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shouldRemoveTooltip(target: Node): target is Element {
|
|
67
|
+
if(!(target instanceof Element)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return target.hasAttribute(`${prefix}-id`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const TooltipDirective: Directive<Element, string|TooltipProps> = {
|
|
75
|
+
beforeMount(target, binding) {
|
|
76
|
+
createTooltip(target, typeof binding.value === 'string' ? {
|
|
77
|
+
title: binding.value
|
|
78
|
+
}: binding.value);
|
|
79
|
+
},
|
|
80
|
+
beforeUnmount(target) {
|
|
81
|
+
destroyTooltip(target);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function TooltipPlugin(app: App<Element>) {
|
|
86
|
+
app.mixin({
|
|
87
|
+
beforeMount() {
|
|
88
|
+
const observer = new MutationObserver((mutations) => {
|
|
89
|
+
for(const { addedNodes, removedNodes } of mutations) {
|
|
90
|
+
addedNodes.forEach((node) => {
|
|
91
|
+
if(shouldCreateTooltip(node)) {
|
|
92
|
+
createTooltip(node);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
removedNodes.forEach((node) => {
|
|
97
|
+
if(shouldRemoveTooltip(node)) {
|
|
98
|
+
destroyTooltip(node);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
observer.observe(document.body, {
|
|
105
|
+
childList: true,
|
|
106
|
+
subtree: true
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
mounted() {
|
|
110
|
+
let el = this.$el;
|
|
111
|
+
|
|
112
|
+
if(this.$el instanceof Text) {
|
|
113
|
+
el = this.$el.parentNode;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const walker = document.createTreeWalker(
|
|
117
|
+
el,
|
|
118
|
+
NodeFilter.SHOW_ALL,
|
|
119
|
+
(node: Node) => {
|
|
120
|
+
if(!(node instanceof Element)) {
|
|
121
|
+
return NodeFilter.FILTER_REJECT;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
while(walker.nextNode()) {
|
|
129
|
+
if(shouldCreateTooltip(walker.currentNode)) {
|
|
130
|
+
createTooltip(walker.currentNode);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
app.directive<Element, string|TooltipProps>('tooltip', TooltipDirective);
|
|
137
|
+
}
|