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..e413c9abfc 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 @@ -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, @@ -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: '', + default: 'Undo', + }, + }); + + await wrapper.find('.sd-tooltip-trigger').trigger('mouseenter'); + await nextTick(); + await nextTick(); + return document.body.querySelector('.sd-tooltip-content'); + }; + + 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'); + }); + }); }); 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..b84483f559 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.vue @@ -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; @@ -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`, }; }; @@ -218,6 +223,7 @@ onBeforeUnmount(() => { ref="contentRef" :class="mergedContentClass" :style="contentStyle" + :data-placement="placement" @mouseenter="handleContentMouseEnter" @mouseleave="handleContentMouseLeave" > @@ -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),