diff --git a/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.test.js b/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.test.js index 395c2669cf..8d085c0483 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.test.js @@ -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: '', 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, { diff --git a/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.vue b/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.vue index af44e677b4..7fb6ffe6ac 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.vue @@ -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 || {}), @@ -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`, }; }; @@ -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;