This commit fixes an issue where the tooltip position becomes inaccurate after resizing the window. The solution uses `autoUpdate` functionality of `floating-ui` to update the position automatically on resize events. This function depends on browser APIs: `IntersectionObserver` and `ResizeObserver`. The official documentation recommends polyfilling those to support old browsers. Polyfilling `ResizeObserver` is already part of the codebase, used by `SizeObserver.vue`. This commit refactors polyfill logic to be reusable across different components, and reuses it on `TooltipWrapper.vue`. Polyfilling `IntersectionObserver` is ignored due to this API being older and more widely supported.
171 lines
4.3 KiB
Vue
171 lines
4.3 KiB
Vue
<template>
|
|
<div class="tooltip">
|
|
<div
|
|
class="tooltip__trigger"
|
|
ref="triggeringElement">
|
|
<slot />
|
|
</div>
|
|
<div
|
|
class="tooltip__display"
|
|
ref="tooltipDisplayElement"
|
|
:style="displayStyles"
|
|
>
|
|
<div class="tooltip__content">
|
|
<slot name="tooltip" />
|
|
</div>
|
|
<div
|
|
ref="arrowElement"
|
|
class="tooltip__arrow"
|
|
:style="arrowStyles"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import {
|
|
useFloating, arrow, shift, flip, Placement, offset, Side, Coords, autoUpdate,
|
|
} from '@floating-ui/vue';
|
|
import { defineComponent, ref, computed } from 'vue';
|
|
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
|
|
import type { CSSProperties } from 'vue/types/jsx'; // In Vue 3.0 import from 'vue'
|
|
|
|
const GAP_BETWEEN_TOOLTIP_AND_TRIGGER_IN_PX = 2;
|
|
const ARROW_SIZE_IN_PX = 4;
|
|
const MARGIN_FROM_DOCUMENT_EDGE_IN_PX = 2;
|
|
|
|
export default defineComponent({
|
|
setup() {
|
|
const tooltipDisplayElement = ref<HTMLElement | undefined>();
|
|
const triggeringElement = ref<HTMLElement | undefined>();
|
|
const arrowElement = ref<HTMLElement | undefined>();
|
|
const placement = ref<Placement>('top');
|
|
|
|
useResizeObserverPolyfill();
|
|
|
|
const { floatingStyles, middlewareData } = useFloating(
|
|
triggeringElement,
|
|
tooltipDisplayElement,
|
|
{
|
|
placement: ref(placement),
|
|
middleware: [
|
|
offset(ARROW_SIZE_IN_PX + GAP_BETWEEN_TOOLTIP_AND_TRIGGER_IN_PX),
|
|
/* Shifts the element along the specified axes in order to keep it in view. */
|
|
shift({
|
|
padding: MARGIN_FROM_DOCUMENT_EDGE_IN_PX,
|
|
}),
|
|
/* Changes the placement of the floating element in order to keep it in view,
|
|
with the ability to flip to any placement. */
|
|
flip(),
|
|
arrow({ element: arrowElement }),
|
|
],
|
|
whileElementsMounted: autoUpdate,
|
|
},
|
|
);
|
|
const arrowStyles = computed<CSSProperties>(() => {
|
|
if (!middlewareData.value.arrow) {
|
|
return {
|
|
display: 'none',
|
|
};
|
|
}
|
|
return {
|
|
...getArrowPositionStyles(middlewareData.value.arrow, placement.value),
|
|
...getArrowAppearanceStyles(),
|
|
};
|
|
});
|
|
|
|
return {
|
|
tooltipDisplayElement,
|
|
triggeringElement,
|
|
displayStyles: floatingStyles,
|
|
arrowStyles,
|
|
arrowElement,
|
|
placement,
|
|
};
|
|
},
|
|
});
|
|
|
|
function getArrowAppearanceStyles(): CSSProperties {
|
|
return {
|
|
width: `${ARROW_SIZE_IN_PX * 2}px`,
|
|
height: `${ARROW_SIZE_IN_PX * 2}px`,
|
|
rotate: '45deg',
|
|
};
|
|
}
|
|
|
|
function getArrowPositionStyles(
|
|
coordinations: Partial<Coords>,
|
|
placement: Placement,
|
|
): CSSProperties {
|
|
const style: CSSProperties = {};
|
|
style.position = 'absolute';
|
|
const { x, y } = coordinations;
|
|
if (x) {
|
|
style.left = `${x}px`;
|
|
} else if (y) { // either X or Y is calculated
|
|
style.top = `${y}px`;
|
|
}
|
|
const oppositeSide = getCounterpartBoxOffsetProperty(placement) as never;
|
|
// Cast to `never` due to ts(2590) from JSX import. Remove after migrating to Vue 3.0.
|
|
style[oppositeSide] = `-${ARROW_SIZE_IN_PX}px`;
|
|
return style;
|
|
}
|
|
|
|
function getCounterpartBoxOffsetProperty(placement: Placement): keyof CSSProperties {
|
|
const sideCounterparts: Record<Side, keyof CSSProperties> = {
|
|
top: 'bottom',
|
|
right: 'left',
|
|
bottom: 'top',
|
|
left: 'right',
|
|
};
|
|
const currentSide = placement.split('-')[0] as Side;
|
|
return sideCounterparts[currentSide];
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
@use "@/presentation/assets/styles/main" as *;
|
|
|
|
$color-tooltip-background: $color-primary-darkest;
|
|
|
|
@mixin set-visibility($isVisible: true) {
|
|
@if $isVisible {
|
|
visibility: visible;
|
|
opacity: 1;
|
|
transition: opacity .15s;
|
|
} @else {
|
|
visibility: hidden;
|
|
opacity: 0;
|
|
transition: opacity .15s, visibility .15s;
|
|
}
|
|
}
|
|
|
|
.tooltip {
|
|
display: inline-flex;
|
|
}
|
|
|
|
.tooltip__display {
|
|
@include set-visibility(false);
|
|
}
|
|
|
|
.tooltip__trigger {
|
|
@include hover-or-touch {
|
|
+ .tooltip__display {
|
|
@include set-visibility(true);
|
|
z-index: 10000;
|
|
}
|
|
}
|
|
}
|
|
|
|
.tooltip__content {
|
|
background: $color-tooltip-background;
|
|
color: $color-on-primary;
|
|
border-radius: 16px;
|
|
padding: 5px 10px 4px;
|
|
}
|
|
|
|
.tooltip__arrow {
|
|
background: $color-tooltip-background;
|
|
}
|
|
</style>
|