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 @@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { toolbarIcons } from './toolbarIcons.js';
import { useHighContrastMode } from '../../composables/use-high-contrast-mode';
import { computeTypeahead, findPrefixMatchIndex, normalizeCustomFontFamily } from './font-typeahead.js';
import { getAnchoredPosition, getAvailableSpaceForPlacement } from '../../utils/anchored-position.js';

const props = defineProps({
item: {
Expand Down Expand Up @@ -95,20 +96,12 @@ const updatePosition = () => {
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const menuEl = popupRef.value;
const menuHeight = menuEl?.scrollHeight ?? menuEl?.offsetHeight ?? 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const gutter = 8;
const gap = 4;
const belowTop = rect.bottom + gap;
const aboveBottom = rect.top - gap;
const availableBelow = Math.max(0, viewportHeight - belowTop - gutter);
const availableAbove = Math.max(0, aboveBottom - gutter);
const openAbove = availableBelow < menuHeight && availableAbove > availableBelow;
const maxHeight = openAbove ? availableAbove : availableBelow;
const renderHeight = menuHeight ? Math.min(menuHeight, maxHeight) : maxHeight;
const top = openAbove ? Math.max(gutter, aboveBottom - renderHeight) : belowTop;
const left = Math.min(Math.max(gutter, rect.left), Math.max(gutter, viewportWidth - rect.width - gutter));
const { top, left, computedPlacement } = getAnchoredPosition(trigger, menuEl, {
placement: 'bottom-start',
offset: gap,
});
const { maxHeight } = getAvailableSpaceForPlacement(trigger, computedPlacement, gap);

menuPosition.value = {
top: `${top}px`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,45 @@ describe('SdTooltip', () => {
await nextTick();
expect(document.body.querySelector('.sd-tooltip-content')).toBeNull();
});

it('flips to bottom placement when there is not enough space above the trigger', async () => {
// Trigger at top=20, bottom=40 in a 768px tall viewport.
// offset=10, GUTTER=8 → availableAbove = max(0, 20 - 10 - 8) = 2
// availableBelow = max(0, 768 - 40 - 10 - 8) = 710
// contentHeight=60 > availableAbove=2, so the tooltip must flip to 'bottom'.
Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true });
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });

// happy-dom does not compute layout; mock geometry so the flip condition and position check fire.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
return this.classList.contains('sd-tooltip-content') ? 60 : 0;
});

vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
if (this.classList.contains('sd-tooltip-trigger')) {
return { top: 20, bottom: 40, left: 400, right: 420, width: 20, height: 20, x: 400, y: 20, toJSON() {} };
}
return { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {} };
});

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

await wrapper.find('.sd-tooltip-trigger').trigger('mouseenter');
await nextTick();

const arrowEl = document.body.querySelector('.sd-tooltip-arrow');
expect(arrowEl).not.toBeNull();
expect(arrowEl.classList.contains('sd-tooltip-arrow-bottom')).toBe(true);

// Tooltip must be positioned below the trigger (not cut off above)
const topValue = parseInt(document.body.querySelector('.sd-tooltip-content').style.top, 10);
expect(topValue).toBeGreaterThanOrEqual(40);
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, useAttrs, watch } from 'vue';
import { getAnchoredPosition } from '../../utils/anchored-position';

defineOptions({
inheritAttrs: false,
Expand Down Expand Up @@ -38,12 +39,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',
`fade-in-scale-up-transition-enter-active-${placement.value}`,
attrs.class,
]);
const contentStyle = computed(() => ({
...props.contentStyle,
...(attrs.style || {}),
Expand Down Expand Up @@ -86,17 +92,14 @@ const scheduleAutoHide = () => {
const updatePosition = () => {
if (!triggerRef.value || !contentRef.value) return;

const triggerRect = triggerRef.value.getBoundingClientRect();
const contentWidth = contentRef.value.offsetWidth;
const contentHeight = contentRef.value.offsetHeight;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const gutter = 8;

let left = triggerRect.left + triggerRect.width / 2 - contentWidth / 2;
left = Math.max(gutter, Math.min(left, viewportWidth - contentWidth - gutter));
const { top, left, computedPlacement } = getAnchoredPosition(triggerRef.value, contentRef.value, {
placement: 'top',
offset: 10,
});

placement.value = computedPlacement;
position.value = {
top: `${triggerRect.top - contentHeight - 10}px`,
top: `${top}px`,
left: `${left}px`,
};
};
Expand Down Expand Up @@ -221,7 +224,7 @@ onBeforeUnmount(() => {
@mouseenter="handleContentMouseEnter"
@mouseleave="handleContentMouseLeave"
>
<span class="sd-tooltip-arrow" aria-hidden="true" />
<span :class="`sd-tooltip-arrow sd-tooltip-arrow-${placement}`" aria-hidden="true" />
<slot />
</div>
</Transition>
Expand All @@ -248,18 +251,33 @@ onBeforeUnmount(() => {
.sd-tooltip-arrow {
position: absolute;
left: 50%;
bottom: -5px;
width: 10px;
height: 10px;
background-color: var(--sd-ui-tooltip-bg, #262626);
transform: translateX(-50%) rotate(45deg);
}

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

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

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

@dani-beltran dani-beltran Jul 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the animation origin to be position agnostic, since the tooltip could be now on top or bottom positions. It looks good, barely noticeable difference. However, this is modifying the original animation, so I am considering changing it.

@dani-beltran dani-beltran Jul 20, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a simple solution to make the animation origin match the dynamic tooltip position. 🟢

}

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

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

.fade-in-scale-up-transition-enter-active {
transition:
opacity 0.2s cubic-bezier(0, 0, 0.2, 1),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup>
import { computed, defineComponent, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { getAnchoredPosition, getAvailableSpaceForPlacement } from '../../utils/anchored-position.js';

const props = defineProps({
options: {
Expand Down Expand Up @@ -89,29 +90,14 @@ const updateMenuPosition = () => {
if (!triggerRef.value) return;
const rect = triggerRef.value.getBoundingClientRect();
const menuEl = menuRef.value;
const menuWidth = menuEl?.offsetWidth ?? 0;
const menuHeight = menuEl?.scrollHeight ?? menuEl?.offsetHeight ?? 0;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const gutter = 8;
const gap = 4;
const belowTop = rect.bottom + gap;
const aboveBottom = rect.top - gap;
const availableBelow = Math.max(0, viewportHeight - belowTop - gutter);
const availableAbove = Math.max(0, aboveBottom - gutter);
const openAbove = availableBelow < menuHeight && availableAbove > availableBelow;
const maxHeight = openAbove ? availableAbove : availableBelow;
const menuRenderHeight = menuHeight ? Math.min(menuHeight, maxHeight) : maxHeight;
const top = openAbove ? Math.max(gutter, aboveBottom - menuRenderHeight) : belowTop;
let left = rect.left;

if (props.placement === 'bottom-end') {
left = rect.right - menuWidth;
}

// Prevent horizontal overflow outside viewport.
const maxLeft = Math.max(gutter, viewportWidth - menuWidth - gutter);
left = Math.min(Math.max(gutter, left), maxLeft);
const { top, left, computedPlacement } = getAnchoredPosition(triggerRef.value, menuEl, {
placement: props.placement,
offset: gap,
});

const { maxHeight } = getAvailableSpaceForPlacement(triggerRef.value, computedPlacement, gap);

menuPosition.value = {
top: `${top}px`,
Expand Down
Loading