Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
85ca5d4
fix(super-editor): toolbar tooltips are not cut off by edge of the wi…
dani-beltran Jul 17, 2026
9280d31
chore(super-editor): refactor ToolbarDropdown to use anchored-positio…
dani-beltran Jul 17, 2026
98aaca6
chore(super-editor): refactor FontFamilyCombobox to use anchored-posi…
dani-beltran Jul 17, 2026
692cca4
chore(super-editor): added test for the SdTooltip fliping fix
dani-beltran Jul 17, 2026
bde822f
chore(super-editor): added test for anchored-position utility fns
dani-beltran Jul 17, 2026
efcc505
fix(super-editor): avoid edge case where popover positioning could go…
dani-beltran Jul 18, 2026
b27f96e
fix(super-editor): measuring full menu height for dropdowns dynamic p…
dani-beltran Jul 20, 2026
ce793a3
fix(super-editor): tooltip animation origin matches its position
dani-beltran Jul 20, 2026
09a2bca
chore(super-ditor): removed unnecesary code
dani-beltran Jul 20, 2026
38aea83
fix(super-editor): context menu no more clipping outside editor
dani-beltran Jul 28, 2026
1118b3f
chore(super-editor): update tests for components using anchored posit…
dani-beltran Jul 28, 2026
6a5c6f1
chore(super-editor): added tests for anchored-position util new param…
dani-beltran Jul 28, 2026
a04b194
fix(super-editor): positioning available space on top and left update…
dani-beltran Jul 28, 2026
2edbe98
chore(super-editor): restored context menu original behaviour
dani-beltran Jul 30, 2026
49be664
fix(super-editor): anchored position function take boundary into acco…
dani-beltran Jul 30, 2026
49b2547
chore(super-editor): update anchored position util tests
dani-beltran Jul 30, 2026
5ff59f6
fix(super-editor): flip anchored position for the secondary axis as well
dani-beltran Jul 30, 2026
58e18f0
chore(super-editor): refactor font family combobox to avoid redundant…
dani-beltran Jul 30, 2026
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(rect, menuEl, {
placement: 'bottom-start',
offset: gap,
});
const { maxHeight } = getAvailableSpaceForPlacement(rect, computedPlacement, { offset: 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
Expand Up @@ -8,6 +8,7 @@ let wrapper;
const originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight');
const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight');
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView');
const nativeGetComputedStyle = window.getComputedStyle.bind(window);

const restoreDescriptor = (target, property, descriptor) => {
if (descriptor) {
Expand All @@ -18,6 +19,7 @@ const restoreDescriptor = (target, property, descriptor) => {
};

afterEach(() => {
vi.restoreAllMocks();
wrapper?.unmount();
wrapper = null;
document.body.innerHTML = '';
Expand Down Expand Up @@ -127,6 +129,12 @@ describe('ToolbarDropdown keyboard focus', () => {
return this.classList?.contains('toolbar-dropdown-menu') ? 500 : 0;
},
});
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
if (element.classList?.contains('toolbar-dropdown-menu')) {
return { overflowY: 'auto' };
}
return nativeGetComputedStyle(element);
});

const Harness = defineComponent({
components: { ToolbarDropdown },
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, { offset: gap });

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