Skip to content
9 changes: 9 additions & 0 deletions resources/css/components/tooltips.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@
.v-popper--theme-tooltip .v-popper__inner {
padding: 5px 10px !important;
}

.v-popper--theme-tooltip a {
@apply underline;
}

.v-popper--theme-tooltip:has(.tooltip-popper-interactive),
.v-popper--theme-tooltip:has(.tooltip-popper-interactive) .v-popper__wrapper {
pointer-events: auto;
}
28 changes: 24 additions & 4 deletions resources/js/components/Tooltips.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
<script setup>
import { ref, watch, nextTick } from 'vue';
import { ref, watch, nextTick, computed } from 'vue';
import { Tooltip as VTooltip } from 'floating-vue';
import DOMPurify from 'dompurify';
import { useTooltip } from '@/composables/tooltip.js';
import useCopy from '@/composables/copy';

const { isVisible, content, html, targetEl } = useTooltip();
const { isVisible, content, html, copyable, targetEl, registerContentEl } = useTooltip();
const { copySupported, copy } = useCopy();

const showTooltip = ref(false);
const wrapperStyle = ref({});
const spanStyle = ref({});
const tooltipKey = ref(0);
const displayContent = ref('');
const displayHtml = ref(false);
const displayCopyable = ref(false);
const isInteractive = computed(() => displayHtml.value || displayCopyable.value);

function updatePosition() {
if (!targetEl.value) {
Expand Down Expand Up @@ -42,6 +46,7 @@ watch([isVisible, targetEl, content], async ([visible, target]) => {
if (visible && target) {
// Update content and position (handles both initial show and target changes)
displayHtml.value = html.value;
displayCopyable.value = copyable.value;
displayContent.value = displayHtml.value ? DOMPurify.sanitize(content.value ?? '') : content.value;
updatePosition();
tooltipKey.value++;
Expand All @@ -66,8 +71,23 @@ watch([isVisible, targetEl, content], async ([visible, target]) => {
>
<span :style="spanStyle" />
<template #popper>
<div v-if="displayHtml" v-html="displayContent" />
<template v-else>{{ displayContent }}</template>
<div
:ref="registerContentEl"
:class="{ 'tooltip-popper-interactive': isInteractive }"
>
<div v-if="displayHtml" v-html="displayContent" />
<span
v-else-if="displayCopyable && copySupported"
class="cursor-pointer hover:underline"
role="button"
tabindex="0"
:aria-label="__('Copy :handle to clipboard', { handle: displayContent })"
@click.stop="copy(displayContent)"
@keydown.enter.prevent="copy(displayContent)"
@keydown.space.prevent="copy(displayContent)"
>{{ displayContent }}</span>
<template v-else>{{ displayContent }}</template>
</div>
</template>
</VTooltip>
</div>
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/ui/Publish/Field.vue
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ const fieldtypeComponentEvents = computed(() => ({
/>
</Transition>
<template v-if="shouldShowLabelText">
<span v-tooltip="config.handle">
<span v-tooltip="{ content: config.handle, copyable: true }">
{{ __(config.display) }}
</span>
</template>
Expand Down
203 changes: 189 additions & 14 deletions resources/js/composables/tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,190 @@ import { ref, shallowRef, readonly } from 'vue';
const isVisible = ref(false);
const content = ref('');
const html = ref(false);
const copyable = ref(false);
const targetEl = shallowRef(null);
const contentEl = shallowRef(null);

let hideTimeout = null;
let showTimeout = null;
let pendingEl = null;
let tracking = false;
let pointer = null;

const EDGE = 12;
const GAP_SLACK = 16;

function isInteractive() {
return html.value || copyable.value;
}

function popperRect() {
const el = contentEl.value;
if (!el?.isConnected) return null;

return (el.closest('.v-popper__popper') ?? el).getBoundingClientRect();
}

function triggerRect() {
const el = targetEl.value;

return el?.isConnected ? el.getBoundingClientRect() : null;
}

function contains(rect, x, y, pad = EDGE) {
return (
!!rect &&
x >= rect.left - pad &&
x <= rect.right + pad &&
y >= rect.top - pad &&
y <= rect.bottom + pad
);
}

export function tooltipGapRect(trigger, popper) {
if (!trigger || !popper) return null;

if (popper.bottom <= trigger.top) {
return {
left: Math.min(trigger.left, popper.left) - GAP_SLACK,
right: Math.max(trigger.right, popper.right) + GAP_SLACK,
top: popper.bottom,
bottom: trigger.top,
};
}

if (popper.top >= trigger.bottom) {
return {
left: Math.min(trigger.left, popper.left) - GAP_SLACK,
right: Math.max(trigger.right, popper.right) + GAP_SLACK,
top: trigger.bottom,
bottom: popper.top,
};
}

if (popper.right <= trigger.left) {
return {
left: popper.right,
right: trigger.left,
top: Math.min(trigger.top, popper.top) - GAP_SLACK,
bottom: Math.max(trigger.bottom, popper.bottom) + GAP_SLACK,
};
}

if (popper.left >= trigger.right) {
return {
left: trigger.right,
right: popper.left,
top: Math.min(trigger.top, popper.top) - GAP_SLACK,
bottom: Math.max(trigger.bottom, popper.bottom) + GAP_SLACK,
};
}

return null;
}

export function isInTooltipHoverRegion(x, y, trigger, popper, edge = EDGE) {
if (contains(trigger, x, y, edge) || contains(popper, x, y, edge)) return true;

// Popper isn't measured yet — keep the tooltip so the pointer can cross the gap.
if (trigger && !popper) return true;

return !!trigger && !!popper && contains(tooltipGapRect(trigger, popper), x, y, 0);
}

function isTriggerEngaged() {
const el = targetEl.value;
if (!el?.isConnected) return false;

return (
el.matches(':hover') ||
el.matches(':focus') ||
el.matches(':focus-visible') ||
el.contains(document.activeElement)
);
}

function isPopperEngaged() {
const el = contentEl.value;
if (!el?.isConnected) return false;

const popper = el.closest('.v-popper__popper') ?? el;

return popper.matches(':hover') || popper.contains(document.activeElement);
}

function isOverInteractiveTooltip() {
if (isTriggerEngaged() || isPopperEngaged()) return true;

if (pointer) {
return isInTooltipHoverRegion(pointer.x, pointer.y, triggerRect(), popperRect());
}

// Still mounting, and we have not seen a pointer yet.
return isVisible.value && isInteractive() && !popperRect();
}

function onPointerMove(event) {
pointer = { x: event.clientX, y: event.clientY };

if (!isOverInteractiveTooltip()) dismiss();
}

function startTracking() {
if (tracking) return;
tracking = true;
document.addEventListener('mousemove', onPointerMove, true);
document.addEventListener('mouseleave', dismiss);
window.addEventListener('blur', dismiss);
}

function stopTracking() {
if (!tracking) return;
tracking = false;
pointer = null;
document.removeEventListener('mousemove', onPointerMove, true);
document.removeEventListener('mouseleave', dismiss);
window.removeEventListener('blur', dismiss);
}

function setContent(el, options) {
targetEl.value = el;

if (typeof options === 'string') {
content.value = options;
html.value = false;
copyable.value = false;
} else if (options && typeof options === 'object') {
content.value = options.content || '';
html.value = options.html || false;
copyable.value = options.copyable || false;
} else {
content.value = '';
html.value = false;
copyable.value = false;
}

isInteractive() ? startTracking() : stopTracking();
}

function show(el, options) {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
function dismiss() {
stopTracking();

if (showTimeout) {
clearTimeout(showTimeout);
showTimeout = null;
}

pendingEl = null;

isVisible.value = false;
targetEl.value = null;
contentEl.value = null;
content.value = '';
html.value = false;
copyable.value = false;
}

function show(el, options) {
if (showTimeout) {
clearTimeout(showTimeout);
}
Expand All @@ -39,7 +197,10 @@ function show(el, options) {
return;
}

pendingEl = el;

showTimeout = setTimeout(() => {
pendingEl = null;
setContent(el, options);

if (content.value) {
Expand All @@ -49,26 +210,40 @@ function show(el, options) {
}

function hide() {
if (showTimeout) {
clearTimeout(showTimeout);
showTimeout = null;
if (isVisible.value && isInteractive()) {
if (isOverInteractiveTooltip()) return;
}

hideTimeout = setTimeout(() => {
isVisible.value = false;
targetEl.value = null;
content.value = '';
html.value = false;
}, 50);
dismiss();
}

function dismissFor(el, event) {
const next = event?.relatedTarget;
const popper = contentEl.value?.closest?.('.v-popper__popper') ?? contentEl.value;

if (next && popper?.contains?.(next)) return;

if (targetEl.value === el || pendingEl === el) dismiss();
}

function registerContentEl(el) {
contentEl.value = el ?? null;

if (el && isVisible.value && isInteractive() && !isOverInteractiveTooltip()) {
dismiss();
}
}

export function useTooltip() {
return {
isVisible: readonly(isVisible),
content: readonly(content),
html: readonly(html),
copyable: readonly(copyable),
targetEl: readonly(targetEl),
show,
hide,
dismissFor,
registerContentEl,
};
}
15 changes: 6 additions & 9 deletions resources/js/directives/tooltip.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useTooltip } from '@/composables/tooltip.js';

const { show, hide } = useTooltip();
const { show, hide, dismissFor } = useTooltip();

function getOptions(binding) {
const value = binding.value;
Expand All @@ -19,20 +19,17 @@ function handleMouseEnter(el, binding) {
}
}

function handleMouseLeave() {
hide();
}

export default {
mounted(el, binding) {
el._tooltipBinding = binding;
el._tooltipMouseEnter = () => handleMouseEnter(el, el._tooltipBinding);
el._tooltipMouseLeave = handleMouseLeave;
el._tooltipMouseLeave = hide;
el._tooltipBlur = (event) => dismissFor(el, event);

el.addEventListener('mouseenter', el._tooltipMouseEnter);
el.addEventListener('mouseleave', el._tooltipMouseLeave);
el.addEventListener('focus', el._tooltipMouseEnter);
el.addEventListener('blur', el._tooltipMouseLeave);
el.addEventListener('blur', el._tooltipBlur);
},

updated(el, binding) {
Expand All @@ -43,7 +40,7 @@ export default {
el.removeEventListener('mouseenter', el._tooltipMouseEnter);
el.removeEventListener('mouseleave', el._tooltipMouseLeave);
el.removeEventListener('focus', el._tooltipMouseEnter);
el.removeEventListener('blur', el._tooltipMouseLeave);
hide();
el.removeEventListener('blur', el._tooltipBlur);
dismissFor(el);
},
};
Loading
Loading