diff --git a/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.vue b/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.vue index 81049236d2..48aeefc75c 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.vue @@ -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: { @@ -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`, 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..77c6b9fc7f 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 @@ -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: '', + 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); + }); }); 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..c5a9ff04f4 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/SdTooltip.vue @@ -1,5 +1,6 @@