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
Expand Up @@ -4,14 +4,32 @@ import { nextTick } from 'vue';
import SdTooltip from './SdTooltip.vue';

describe('SdTooltip', () => {
let mountedWrappers = [];

// mount() wrapper so afterEach can unmount every instance: SdTooltip registers
// window/document listeners while open, and clearing document.body does not
// unmount Vue components, so onBeforeUnmount cleanup would never run.
const trackedMount = (...args) => {
const wrapper = mount(...args);
mountedWrappers.push(wrapper);
return wrapper;
};

const unmountAll = () => {
mountedWrappers.forEach((wrapper) => wrapper.unmount());
mountedWrappers = [];
};

afterEach(() => {
unmountAll();
vi.useRealTimers();
vi.restoreAllMocks();
document.body.innerHTML = '';
});

it('auto-hides after the configured visible duration', async () => {
vi.useFakeTimers();
const wrapper = mount(SdTooltip, {
const wrapper = trackedMount(SdTooltip, {
attachTo: document.body,
props: {
delay: 0,
Expand All @@ -36,4 +54,90 @@ describe('SdTooltip', () => {
await nextTick();
expect(document.body.querySelector('.sd-tooltip-content')).toBeNull();
});

describe('positioning', () => {
const TOOLTIP_WIDTH = 120;
const TOOLTIP_HEIGHT = 34;

const makeRect = ({ top, left, width, height }) => ({
top,
left,
width,
height,
right: left + width,
bottom: top + height,
x: left,
y: top,
});

// happy-dom does not compute layout: every measurement API returns 0. Geometry is
// injected through all of them (offset* and getBoundingClientRect) so the tests do
// not depend on which one the component reads.
const stubLayout = (triggerRect) => {
const isTooltipContent = (el) => el.classList?.contains('sd-tooltip-content');
const contentRect = makeRect({ top: 0, left: 0, width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT });

vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function () {
return isTooltipContent(this) ? TOOLTIP_WIDTH : triggerRect.width;
});
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
return isTooltipContent(this) ? TOOLTIP_HEIGHT : triggerRect.height;
});
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function () {
return isTooltipContent(this) ? contentRect : triggerRect;
});
};

const mountAndOpen = async (triggerRect) => {
stubLayout(triggerRect);

const wrapper = trackedMount(SdTooltip, {
attachTo: document.body,
props: {
delay: 0,
duration: 0,
},
slots: {
trigger: '<button type="button">Undo</button>',
default: 'Undo',
},
});

await wrapper.find('.sd-tooltip-trigger').trigger('mouseenter');
await nextTick();
await nextTick();
return document.body.querySelector('.sd-tooltip-content');
};
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

const renderedTop = (content) => parseFloat(content.style.top);

it('renders fully above the trigger when there is room', async () => {
const triggerRect = makeRect({ top: 200, left: 300, width: 32, height: 32 });
const content = await mountAndOpen(triggerRect);

expect(renderedTop(content)).toBeGreaterThanOrEqual(0);
expect(renderedTop(content) + TOOLTIP_HEIGHT).toBeLessThanOrEqual(triggerRect.top);
});

it('stays inside the viewport and clear of the trigger when the trigger is flush with the top', async () => {
const triggerRect = makeRect({ top: 0, left: 100, width: 32, height: 32 });
const content = await mountAndOpen(triggerRect);

expect(renderedTop(content)).toBeGreaterThanOrEqual(0);
expect(renderedTop(content)).toBeGreaterThanOrEqual(triggerRect.bottom);
});

// data-placement is the styling contract the component's own CSS uses to orient
// the arrow and the transition origin toward the trigger.
it('marks the placement so the arrow points at the trigger from either side', async () => {
const flushTopRect = makeRect({ top: 0, left: 100, width: 32, height: 32 });
expect((await mountAndOpen(flushTopRect)).dataset.placement).toBe('bottom');

unmountAll();
vi.restoreAllMocks();

const roomyRect = makeRect({ top: 200, left: 300, width: 32, height: 32 });
expect((await mountAndOpen(roomyRect)).dataset.placement).toBe('top');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ 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;
Expand Down Expand Up @@ -91,12 +92,16 @@ const updatePosition = () => {
const contentHeight = contentRef.value.offsetHeight;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 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));

const topAbove = triggerRect.top - contentHeight - offset;
placement.value = topAbove < gutter ? 'bottom' : 'top';

position.value = {
top: `${triggerRect.top - contentHeight - 10}px`,
top: placement.value === 'top' ? `${topAbove}px` : `${triggerRect.bottom + offset}px`,
left: `${left}px`,
};
};
Expand Down Expand Up @@ -218,6 +223,7 @@ onBeforeUnmount(() => {
ref="contentRef"
:class="mergedContentClass"
:style="contentStyle"
:data-placement="placement"
@mouseenter="handleContentMouseEnter"
@mouseleave="handleContentMouseLeave"
>
Expand Down Expand Up @@ -255,11 +261,21 @@ onBeforeUnmount(() => {
transform: translateX(-50%) rotate(45deg);
}

.sd-tooltip-content[data-placement='bottom'] .sd-tooltip-arrow {
bottom: auto;
top: -5px;
}

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

.sd-tooltip-content[data-placement='bottom'].fade-in-scale-up-transition-enter-active,
.sd-tooltip-content[data-placement='bottom'].fade-in-scale-up-transition-leave-active {
transform-origin: top center;
}

.fade-in-scale-up-transition-enter-active {
transition:
opacity 0.2s cubic-bezier(0, 0, 0.2, 1),
Expand Down