Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,96 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { flushPromises, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import SdTooltip from './SdTooltip.vue';

// jsdom reports 0 for offset/client sizes; mock the accessor so positioning math is actually exercised.
const metricRestores = [];
function mockAccessor(target, prop, value) {
const prev = Object.getOwnPropertyDescriptor(target, prop);
Object.defineProperty(target, prop, { configurable: true, get: () => value });
metricRestores.push(() => (prev ? Object.defineProperty(target, prop, prev) : delete target[prop]));
}

async function openTooltipWithTriggerRect(rect) {
const wrapper = mount(SdTooltip, {
attachTo: document.body,
props: { delay: 0, duration: 0 },
slots: { trigger: '<button type="button">Bold</button>', default: 'Bold' },
});
const trigger = wrapper.find('.sd-tooltip-trigger').element;
trigger.getBoundingClientRect = () => ({ ...rect, x: rect.left, y: rect.top, toJSON() {} });
await wrapper.find('.sd-tooltip-trigger').trigger('mouseenter');
await flushPromises();
await nextTick();
return document.body.querySelector('.sd-tooltip-content');
}

describe('SdTooltip', () => {
afterEach(() => {
metricRestores.splice(0).forEach((restore) => restore());
vi.useRealTimers();
document.body.innerHTML = '';
});

it('flips below based on the tooltip height, not only when the trigger touches the top', async () => {
mockAccessor(HTMLElement.prototype, 'offsetHeight', 40);
// above = 30 - 40 - 10 = -20 < gutter → flips below (bottom + 10 = 60).
const content = await openTooltipWithTriggerRect({
top: 30,
bottom: 50,
left: 100,
right: 140,
width: 40,
height: 20,
});
expect(content.style.top).toBe('60px');
expect(content.classList.contains('sd-tooltip-content--bottom')).toBe(true);
});

it('clamps horizontally to clientWidth, excluding the window scrollbar', async () => {
mockAccessor(HTMLElement.prototype, 'offsetWidth', 100);
mockAccessor(document.documentElement, 'clientWidth', 500);
// left 450 clamps to clientWidth - width - gutter = 500 - 100 - 8 = 392 (innerWidth stays 1024).
const content = await openTooltipWithTriggerRect({
top: 500,
bottom: 520,
left: 480,
right: 520,
width: 40,
height: 20,
});
expect(content.style.left).toBe('392px');
});

it('flips below the trigger when there is no room above the viewport top', async () => {
const content = await openTooltipWithTriggerRect({
top: 5,
bottom: 25,
left: 100,
right: 140,
width: 40,
height: 20,
});
expect(content).not.toBeNull();
expect(content.style.top).toBe('35px');
expect(content.classList.contains('sd-tooltip-content--bottom')).toBe(true);
});

it('stays above the trigger when there is room', async () => {
const content = await openTooltipWithTriggerRect({
top: 500,
bottom: 520,
left: 100,
right: 140,
width: 40,
height: 20,
});
expect(content).not.toBeNull();
// contentHeight is 0 in jsdom, so above = top - 0 - 10 = 490.
expect(content.style.top).toBe('490px');
expect(content.classList.contains('sd-tooltip-content--bottom')).toBe(false);
});

it('auto-hides after the configured visible duration', async () => {
vi.useFakeTimers();
const wrapper = mount(SdTooltip, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,17 @@ const isOpen = ref(false);
const triggerRef = ref(null);
const contentRef = ref(null);
const position = ref({ top: '0px', left: '0px' });
const placement = ref('top');

let closeTimeout = null;
let openTimeout = null;
let autoHideTimeout = null;

const mergedContentClass = computed(() => ['sd-tooltip-content', attrs.class]);
const mergedContentClass = computed(() => [
'sd-tooltip-content',
`sd-tooltip-content--${placement.value}`,
attrs.class,
]);
const contentStyle = computed(() => ({
...props.contentStyle,
...(attrs.style || {}),
Expand Down Expand Up @@ -89,14 +94,26 @@ const updatePosition = () => {
const triggerRect = triggerRef.value.getBoundingClientRect();
const contentWidth = contentRef.value.offsetWidth;
const contentHeight = contentRef.value.offsetHeight;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
// clientWidth excludes a window scrollbar, so the tooltip never clamps under it.
const viewportWidth = document.documentElement.clientWidth || window.innerWidth || 0;
const gutter = 8;
const offset = 10;

let left = triggerRect.left + triggerRect.width / 2 - contentWidth / 2;
left = Math.max(gutter, Math.min(left, viewportWidth - contentWidth - gutter));

// Flip below the trigger when placing above would clip the viewport top.
const topAbove = triggerRect.top - contentHeight - offset;
let top = topAbove;
if (topAbove < gutter) {
placement.value = 'bottom';
top = triggerRect.bottom + offset;
} else {
placement.value = 'top';
}

position.value = {
top: `${triggerRect.top - contentHeight - 10}px`,
top: `${top}px`,
left: `${left}px`,
};
};
Expand Down Expand Up @@ -255,6 +272,16 @@ onBeforeUnmount(() => {
transform: translateX(-50%) rotate(45deg);
}

.sd-tooltip-content--bottom .sd-tooltip-arrow {
bottom: auto;
top: -5px;
}

.sd-tooltip-content--bottom.fade-in-scale-up-transition-enter-active,
.sd-tooltip-content--bottom.fade-in-scale-up-transition-leave-active {
transform-origin: top center;
}

.fade-in-scale-up-transition-enter-active,
.fade-in-scale-up-transition-leave-active {
transform-origin: bottom center;
Expand Down
Loading