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 @@